diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 7426dc6a1..022bfebb6 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -100,8 +100,6 @@ steps: plugins: [$CI_TOOLKIT] agents: queue: mac - - label: ":swift: :linux: Build and Test" - command: ".buildkite/commands/run-swift-tests-linux.sh" - label: ":swift: Lint" command: | .buildkite/download-xcframework.sh @@ -254,6 +252,12 @@ steps: artifact_paths: - "native/kotlin/api/kotlin/build/test-results/integrationTest/*.xml" + - label: ":wordpress: :swift: WordPress {{matrix}}" + command: ".buildkite/commands/run-swift-tests-linux.sh" + env: + WORDPRESS_VERSION: "{{matrix}}" + matrix: *wordpress_version_matrix + - label: ":rocket: Publish Swift release $NEW_VERSION" command: .buildkite/release.sh $NEW_VERSION depends_on: swift diff --git a/Cargo.lock b/Cargo.lock index 9bc0f9629..3defc0715 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -565,6 +565,24 @@ dependencies = [ "version_check", ] +[[package]] +name = "cookie_store" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -812,6 +830,15 @@ version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59f8e79d1fbf76bdfbde321e902714bf6c49df88a7dda6fc682fc2979226962d" +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dtoa" version = "1.0.9" @@ -2118,6 +2145,12 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.12" @@ -2737,6 +2770,22 @@ dependencies = [ "yansi", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + [[package]] name = "quinn" version = "0.11.7" @@ -2933,6 +2982,8 @@ dependencies = [ "async-compression", "base64", "bytes", + "cookie", + "cookie_store", "encoding_rs", "futures-core", "futures-util", @@ -5106,6 +5157,7 @@ dependencies = [ "serde", "serde_json", "serde_repr", + "serde_urlencoded", "strum", "strum_macros", "thiserror 2.0.17", diff --git a/Cargo.toml b/Cargo.toml index a15b9c5b9..e46382fce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,7 @@ semver = "1.0" serde = "1.0" serde_json = "1.0" serde_repr = "0.1" +serde_urlencoded = "0.7" serial_test = "3.2" strum = "0.27" strum_macros = "0.27" diff --git a/Makefile b/Makefile index b3e435f64..bb5e924e0 100644 --- a/Makefile +++ b/Makefile @@ -160,10 +160,10 @@ test-swift: $(MAKE) test-swift-$(uname) test-swift-linux: - docker compose run --rm swift make test-swift-linux-in-docker + docker exec -w /app -i wordpress make test-swift-linux-in-docker test-swift-linux-in-docker: swift-linux-library - swift test -Xlinker -Ltarget/release/libwordpressFFI-linux -Xlinker -lwp_mobile + swift test -Xlinker -Ltarget/release/libwordpressFFI-linux -Xlinker -lwp_mobile --no-parallel test-swift-darwin: xcframework swift test diff --git a/Package.resolved b/Package.resolved index e0098449e..bda6bd37c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { - "revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3", - "version" : "1.6.1" + "revision" : "cdd0ef3755280949551dc26dee5de9ddeda89f54", + "version" : "1.6.2" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-docc-plugin", "state" : { - "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64", - "version" : "1.4.3" + "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", + "version" : "1.4.5" } }, { diff --git a/docker-compose.yml b/docker-compose.yml index 36ec40e89..0255b146e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: context: . dockerfile: wordpress.Dockerfile args: - WORDPRESS_VERSION: ${WORDPRESS_VERSION:-latest} + WORDPRESS_VERSION: ${WORDPRESS_VERSION:-6.8.1} container_name: 'wordpress' ports: - '80:80' @@ -30,17 +30,6 @@ services: timeout: 1s retries: 30 - swift: - build: - context: . - dockerfile: swift.Dockerfile - volumes: - - ./:/app - working_dir: /app - environment: - - TEST_ALL_PLUGINS - - CARGO_HOME=/app/.cargo - database: image: 'public.ecr.aws/docker/library/mariadb:11.2' ports: diff --git a/integration_test_credentials/src/lib.rs b/integration_test_credentials/src/lib.rs index abf3af8a9..2d4353cef 100644 --- a/integration_test_credentials/src/lib.rs +++ b/integration_test_credentials/src/lib.rs @@ -7,6 +7,7 @@ pub struct TestCredentials { pub admin_username: &'static str, pub admin_password: &'static str, pub admin_password_uuid: &'static str, + pub admin_account_password: &'static str, pub subscriber_username: &'static str, pub subscriber_password: &'static str, pub subscriber_password_uuid: &'static str, diff --git a/native/swift/Sources/wordpress-api/WordPressLoginClient.swift b/native/swift/Sources/wordpress-api/WordPressLoginClient.swift index 18ac03b7a..a14a46f43 100644 --- a/native/swift/Sources/wordpress-api/WordPressLoginClient.swift +++ b/native/swift/Sources/wordpress-api/WordPressLoginClient.swift @@ -9,11 +9,16 @@ public final class WordPressLoginClient: @unchecked Sendable { private let requestExecutor: SafeRequestExecutor private let client: UniffiWpLoginClient + private let middleware: MiddlewarePipeline public convenience init( urlSession: URLSession, middleware: MiddlewarePipeline = .default ) { + precondition(urlSession.configuration.httpCookieStorage != nil) + precondition(urlSession.configuration.httpShouldSetCookies == true) + precondition(urlSession.configuration.httpCookieAcceptPolicy != .never) + self.init(requestExecutor: WpRequestExecutor(urlSession: urlSession), middleware: middleware) } @@ -22,6 +27,7 @@ public final class WordPressLoginClient: @unchecked Sendable { middleware: MiddlewarePipeline = .default ) { self.requestExecutor = requestExecutor + self.middleware = middleware self.client = UniffiWpLoginClient(requestExecutor: requestExecutor, middlewarePipeline: middleware) } @@ -60,6 +66,22 @@ public final class WordPressLoginClient: @unchecked Sendable { public func credentials(from callbackUrl: URL) throws -> WpApiApplicationPasswordDetails { try extractLoginDetailsFromUrl(url: callbackUrl.absoluteString) } + + public func authenticateTemporarily( + username: String, + password: String, + details: AutoDiscoveryAttemptSuccess + ) async throws -> WordPressAPI { + let nonceRetrieval = WpRestNonceRetrieval(details: details, requestExecutor: requestExecutor) + let nonce = try await nonceRetrieval.getNonce(username: username, password: password) + return WordPressAPI( + apiUrlResolver: WpOrgSiteApiUrlResolver(apiRootUrl: details.apiRootUrl), + authenticationProvider: .staticWithAuth(auth: .nonce(nonce: nonce)), + executor: requestExecutor, + middlewarePipeline: middleware, + appNotifier: nil + ) + } } extension AutoDiscoveryAttemptSuccess { diff --git a/native/swift/Tests/integration-tests/BlockSettingsTests.swift b/native/swift/Tests/integration-tests/BlockSettingsTests.swift index 147d6741a..5ea7aa979 100644 --- a/native/swift/Tests/integration-tests/BlockSettingsTests.swift +++ b/native/swift/Tests/integration-tests/BlockSettingsTests.swift @@ -2,7 +2,7 @@ import Foundation import WordPressAPI import Testing -@Suite +@Suite(.serialized) struct BlockSettingsTests { let api = WordPressAPI.admin() diff --git a/native/swift/Tests/integration-tests/CancellationTests.swift b/native/swift/Tests/integration-tests/CancellationTests.swift index 96420be80..32b3f209f 100644 --- a/native/swift/Tests/integration-tests/CancellationTests.swift +++ b/native/swift/Tests/integration-tests/CancellationTests.swift @@ -6,6 +6,7 @@ import Testing #if os(macOS) +@Suite(.serialized) struct CancellationTests { let api = WordPressAPI.admin() diff --git a/native/swift/Tests/integration-tests/Helpers.swift b/native/swift/Tests/integration-tests/Helpers.swift index 10d8c5e39..103d93d36 100644 --- a/native/swift/Tests/integration-tests/Helpers.swift +++ b/native/swift/Tests/integration-tests/Helpers.swift @@ -6,13 +6,7 @@ import FoundationNetworking #endif func restoreTestServer() async throws { - #if os(Linux) - // Integration tests are run in a Docker container, where the test site - // hostname is 'wordpress'. - let url = URL(string: "http://wordpress:4000/restore?db=true&plugins=true")! - #else let url = URL(string: "http://localhost:4000/restore?db=true&plugins=true")! - #endif _ = try await URLSession(configuration: .ephemeral).data(from: url) } diff --git a/native/swift/Tests/integration-tests/MediaTests.swift b/native/swift/Tests/integration-tests/MediaTests.swift index df64bb382..0a756c5b4 100644 --- a/native/swift/Tests/integration-tests/MediaTests.swift +++ b/native/swift/Tests/integration-tests/MediaTests.swift @@ -2,7 +2,7 @@ import Foundation @testable import WordPressAPI import Testing -@Suite +@Suite(.serialized) struct MediaTests { let api = WordPressAPI.admin() diff --git a/native/swift/Tests/integration-tests/NonceAuthenticationTests.swift b/native/swift/Tests/integration-tests/NonceAuthenticationTests.swift new file mode 100644 index 000000000..e650e9212 --- /dev/null +++ b/native/swift/Tests/integration-tests/NonceAuthenticationTests.swift @@ -0,0 +1,48 @@ +import Foundation +import WordPressAPI +import WordPressAPIInternal +import Testing + +@Suite(.serialized) +struct NonceAuthenticationTests { + + @Test + func success() async throws { + let credentials = TestCredentials.instance() + let client = WordPressLoginClient(urlSession: .init(configuration: .ephemeral)) + let details = try await client.details(ofSite: credentials.siteUrl) + let api = try await client.authenticateTemporarily( + username: credentials.adminUsername, + password: credentials.adminAccountPassword, + details: details + ) + let loggedIn = try await api.users.retrieveMeWithEditContext().data.username + #expect(loggedIn == credentials.adminUsername) + } + + @Test + func signInWithADifferentUser() async throws { + let credentials = TestCredentials.instance() + let client = WordPressLoginClient(urlSession: .init(configuration: .ephemeral)) + let details = try await client.details(ofSite: credentials.siteUrl) + + // Given the URLSession is already signed in with the admin account. + let api = try await client.authenticateTemporarily( + username: credentials.adminUsername, + password: credentials.adminAccountPassword, + details: details + ) + let loggedIn = try await api.users.retrieveMeWithEditContext().data.username + #expect(loggedIn == credentials.adminUsername) + + // When sign in with another account, an error should be returned. + await #expect(throws: NonceRetrievalError.AlreadyLoggedIn(username: credentials.adminUsername)) { + _ = try await client.authenticateTemporarily( + username: credentials.authorUsername, + password: credentials.authorPassword, + details: details + ) + } + } + +} diff --git a/native/swift/Tests/integration-tests/TestCredentials.swift b/native/swift/Tests/integration-tests/TestCredentials.swift index 0c7bd7d1e..13b04e204 100644 --- a/native/swift/Tests/integration-tests/TestCredentials.swift +++ b/native/swift/Tests/integration-tests/TestCredentials.swift @@ -5,6 +5,7 @@ struct TestCredentials: Decodable { var adminUsername: String var adminPassword: String var adminPasswordUuid: String + var adminAccountPassword: String var subscriberUsername: String var subscriberPassword: String var subscriberPasswordUuid: String @@ -24,6 +25,7 @@ struct TestCredentials: Decodable { case adminUsername = "admin_username" case adminPassword = "admin_password" case adminPasswordUuid = "admin_password_uuid" + case adminAccountPassword = "admin_account_password" case subscriberUsername = "subscriber_username" case subscriberPassword = "subscriber_password" case subscriberPasswordUuid = "subscriber_password_uuid" @@ -46,12 +48,6 @@ struct TestCredentials: Decodable { )! .absoluteURL // swiftlint:disable:next force_try - var result = try! JSONDecoder().decode(Self.self, from: Data(contentsOf: json)) - #if os(Linux) - // Integration tests are run in a Docker container, where the test site - // hostname is 'wordpress'. - result.siteUrl = "http://wordpress" - #endif - return result + return try! JSONDecoder().decode(Self.self, from: Data(contentsOf: json)) } } diff --git a/scripts/docker/install-swift.sh b/scripts/docker/install-swift.sh new file mode 100755 index 000000000..7b5dd6263 --- /dev/null +++ b/scripts/docker/install-swift.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -euo pipefail + +curl -s -o swiftly.tar.gz "https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz" +tar zxf swiftly.tar.gz +rm swiftly.tar.gz + +# If you get an "Unsupported Linux platform" error, check out the supported platforms here: +# https://github.com/swiftlang/swiftly/blob/1.1.0/Sources/LinuxPlatform/Linux.swift#L12-L20 +./swiftly init --assume-yes --skip-install + +apt-get -y -qq install libicu-dev libcurl4-openssl-dev libedit-dev libsqlite3-dev libncurses-dev libpython3-dev libxml2-dev uuid-dev git libstdc++-12-dev + +echo "Installing Swift..." +swiftly install --progress-file /dev/null --use 6.1 diff --git a/scripts/setup-test-site.sh b/scripts/setup-test-site.sh index d777a05ae..e45450211 100755 --- a/scripts/setup-test-site.sh +++ b/scripts/setup-test-site.sh @@ -41,13 +41,14 @@ echo "--- :wordpress: Setting up WordPress" wp core version --extra wp --info +ADMIN_ACCOUNT_PASSWORD="strongpassword" ## Install WordPress wp core install \ --url=localhost \ --title=my-test-site \ --admin_user=test@example.com \ --admin_email=test@example.com \ - --admin_password=strongpassword \ + --admin_password="$ADMIN_ACCOUNT_PASSWORD" \ --skip-email ## Ensure URLs work as expected @@ -279,6 +280,7 @@ create_test_credentials () { admin_username="$ADMIN_USERNAME" \ admin_password="$ADMIN_PASSWORD" \ admin_password_uuid="$ADMIN_PASSWORD_UUID" \ + admin_account_password="$ADMIN_ACCOUNT_PASSWORD" \ subscriber_username="$SUBSCRIBER_USERNAME" \ subscriber_password="$SUBSCRIBER_PASSWORD" \ subscriber_password_uuid="$SUBSCRIBER_PASSWORD_UUID" \ diff --git a/swift.Dockerfile b/swift.Dockerfile deleted file mode 100644 index 882aa599e..000000000 --- a/swift.Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM public.ecr.aws/docker/library/swift:6.1 - -RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ - && apt-get update \ - && apt-get install -y \ - build-essential \ - curl \ - make \ - libssl-dev - -ENV RUSTUP_HOME=/usr/local/rustup \ - CARGO_HOME=/usr/local/cargo \ - PATH=/usr/local/cargo/bin:$PATH - -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -v -y diff --git a/wordpress.Dockerfile b/wordpress.Dockerfile index 3bd7b6190..e75fada98 100644 --- a/wordpress.Dockerfile +++ b/wordpress.Dockerfile @@ -1,4 +1,4 @@ -ARG WORDPRESS_VERSION="latest" +ARG WORDPRESS_VERSION="6.8.1" FROM public.ecr.aws/docker/library/wordpress:${WORDPRESS_VERSION} @@ -56,3 +56,9 @@ RUN mkdir gradle-cache-tmp \ && ./gradlew \ && cd .. \ && rm -rf ./gradle-cache-tmp + +# Setup Swift +ENV PATH="/root/.local/share/swiftly/bin:$PATH" +COPY scripts/docker/install-swift.sh /tmp/install-swift.sh +RUN chmod +x /tmp/install-swift.sh && /tmp/install-swift.sh && rm /tmp/install-swift.sh +RUN swift --version diff --git a/wp_api/Cargo.toml b/wp_api/Cargo.toml index 314ff5c81..87d90031d 100644 --- a/wp_api/Cargo.toml +++ b/wp_api/Cargo.toml @@ -28,13 +28,14 @@ url = { workspace = true } parse_link_header = { workspace = true } paste = { workspace = true } regex = { workspace = true } -reqwest = { workspace = true, features = [ "multipart", "json", "stream", "gzip", "brotli", "zstd", "deflate", "rustls-tls", "hickory-dns" ], optional = true } +reqwest = { workspace = true, features = [ "multipart", "json", "stream", "gzip", "brotli", "zstd", "deflate", "rustls-tls", "hickory-dns", "cookies" ], optional = true } roxmltree = { workspace = true } rustls = { workspace = true, optional = true } scraper = { workspace = true } serde = { workspace = true, features = [ "derive", "rc" ] } serde_json = { workspace = true, features = [ "raw_value" ] } serde_repr = { workspace = true } +serde_urlencoded = { workspace = true } strum = { workspace = true } strum_macros = { workspace = true } thiserror = { workspace = true } diff --git a/wp_api/src/auth.rs b/wp_api/src/auth.rs index 471d7926b..a268b81bf 100644 --- a/wp_api/src/auth.rs +++ b/wp_api/src/auth.rs @@ -1,11 +1,21 @@ -use http::HeaderValue; +use http::{HeaderMap, HeaderValue}; use std::fmt::Debug; use std::sync::{Arc, RwLock}; +use crate::{ + login::{nonce::WpRestNonceRetrieval, url_discovery::AutoDiscoveryAttemptSuccess}, + request::RequestExecutor, +}; + #[derive(Debug, Clone, uniffi::Enum)] pub enum WpAuthentication { AuthorizationHeader { token: String }, Bearer { token: String }, + // Cookies+nonce authentication. + // The "cookies" part is implicitly handled by the HTTP client. + // Since nonce is refreshed often, when using this authentication method, + // the caller should not keep using the same nonce for a long time. + Nonce { nonce: String }, None, } @@ -25,15 +35,21 @@ impl WpAuthentication { } } - pub fn header_value(&self) -> Option { + pub fn insert_header(&self, headers: &mut http::HeaderMap) { match self { - Self::None => None, + Self::None => {} Self::AuthorizationHeader { token } => { - Some(HeaderValue::from_str(&format!("Basic {token}")) - .expect("It shouldn't be possible to build WpAuthentication::AuthorizationHeader with an invalid token")) + let value = HeaderValue::from_str(&format!("Basic {token}")) + .expect("It shouldn't be possible to build WpAuthentication::AuthorizationHeader with an invalid token"); + headers.insert(http::header::AUTHORIZATION, value); } Self::Bearer { token } => { - Some(HeaderValue::from_str(&format!("Bearer {token}")).expect("It shouldn't be possible to build WpAuthentication::Bearer with an invalid token")) + let value = HeaderValue::from_str(&format!("Bearer {token}")).expect("It shouldn't be possible to build WpAuthentication::Bearer with an invalid token"); + headers.insert(http::header::AUTHORIZATION, value); + } + Self::Nonce { nonce } => { + let value = HeaderValue::from_str(nonce).expect("It shouldn't be possible to build WpAuthentication::Nonce with an invalid nonce"); + headers.insert("X-WP-Nonce", value); } } } @@ -62,11 +78,65 @@ impl ModifiableAuthenticationProvider { } impl ModifiableAuthenticationProvider { - pub fn header_value(&self) -> Option { + pub fn insert_header(&self, headers: &mut http::HeaderMap) { self.auth .read() .expect("If the lock is poisoned, there isn't much we can do") - .header_value() + .insert_header(headers) + } +} + +#[derive(Debug, uniffi::Object)] +pub struct CookiesNonceAuthenticationProvider { + username: String, + password: String, + nonce_retrieval: Arc, + auth: RwLock, +} + +#[uniffi::export] +impl CookiesNonceAuthenticationProvider { + #[uniffi::constructor] + pub fn new( + username: String, + password: String, + details: AutoDiscoveryAttemptSuccess, + request_executor: Arc, + ) -> Self { + Self { + username, + password, + nonce_retrieval: Arc::new(WpRestNonceRetrieval::new(details, request_executor)), + auth: RwLock::new(WpAuthentication::None), + } + } +} + +#[async_trait::async_trait] +impl WpDynamicAuthenticationProvider for CookiesNonceAuthenticationProvider { + fn auth(&self) -> WpAuthentication { + self.auth + .read() + .expect("If the lock is poisoned, there isn't much we can do") + .clone() + } + + async fn refresh(&self) -> bool { + match self + .nonce_retrieval + .get_nonce(self.username.clone(), self.password.clone()) + .await + { + Ok(nonce) => { + *self + .auth + .write() + .expect("If the lock is poisoned, there isn't much we can do") = + WpAuthentication::Nonce { nonce }; + true + } + Err(_) => false, + } } } @@ -136,13 +206,15 @@ impl WpAuthenticationProvider { } impl WpAuthenticationProvider { - pub fn auth_header_value(&self) -> Option { + pub fn insert_header(&self, headers: &mut HeaderMap) { match self { - WpAuthenticationProvider::StaticAuthenticationProvider { auth } => auth.header_value(), + WpAuthenticationProvider::StaticAuthenticationProvider { auth } => { + auth.insert_header(headers) + } WpAuthenticationProvider::DynamicAuthenticationProvider { inner } => { - inner.auth().header_value() + inner.auth().insert_header(headers) } - WpAuthenticationProvider::Modifiable { inner } => inner.header_value(), + WpAuthenticationProvider::Modifiable { inner } => inner.insert_header(headers), } } diff --git a/wp_api/src/lib.rs b/wp_api/src/lib.rs index 76a72146b..b638477b6 100644 --- a/wp_api/src/lib.rs +++ b/wp_api/src/lib.rs @@ -217,6 +217,16 @@ pub trait WpAppNotifier: Send + Sync + std::fmt::Debug { async fn requested_with_invalid_authentication(&self, request_url: String); } +#[derive(Debug)] +pub struct EmptyAppNotifier; + +#[async_trait::async_trait] +impl WpAppNotifier for EmptyAppNotifier { + async fn requested_with_invalid_authentication(&self, _request_url: String) { + // no-op + } +} + #[macro_export] macro_rules! generate { ($type_name:ident) => { diff --git a/wp_api/src/login.rs b/wp_api/src/login.rs index bfd63520d..306fc45f8 100644 --- a/wp_api/src/login.rs +++ b/wp_api/src/login.rs @@ -14,6 +14,7 @@ use wp_serde_helper::{ const KEY_APPLICATION_PASSWORDS: &str = "application-passwords"; pub mod login_client; +pub mod nonce; pub mod url_discovery; #[derive(Debug, uniffi::Record)] diff --git a/wp_api/src/login/nonce.rs b/wp_api/src/login/nonce.rs new file mode 100644 index 000000000..5186d2b97 --- /dev/null +++ b/wp_api/src/login/nonce.rs @@ -0,0 +1,195 @@ +use std::sync::Arc; + +use http::HeaderValue; +use url::Url; +use uuid::Uuid; +use wp_localization::{WpMessages, WpSupportsLocalization}; +use wp_localization_macro::WpDeriveLocalizable; + +use crate::{ + EmptyAppNotifier, + login::url_discovery::AutoDiscoveryAttemptSuccess, + prelude::*, + request::{ + RequestMethod, WpNetworkRequestBody, endpoint::users_endpoint::UsersRequestExecutor, + }, +}; + +#[derive(uniffi::Object)] +pub struct WpRestNonceRetrieval { + details: AutoDiscoveryAttemptSuccess, + request_executor: Arc, +} + +impl std::fmt::Debug for WpRestNonceRetrieval { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WpRestNonceRetrieval") + .field("details", &self.details) + .field("request_executor", &"") + .finish() + } +} + +#[uniffi::export] +impl WpRestNonceRetrieval { + #[uniffi::constructor] + pub fn new( + details: AutoDiscoveryAttemptSuccess, + request_executor: Arc, + ) -> Self { + Self { + details, + request_executor, + } + } + + pub async fn get_nonce( + &self, + username: String, + password: String, + ) -> Result { + // First, try to get the nonce directly. This HTTP request returns + // a valid nonce if the underlying `request_executor` has valid cookies. + let mut nonce = self.nonce_from_request(self.nonce_request()).await; + + // If that fails, try to log in with the provided username and password + if nonce.is_err() { + nonce = self + .nonce_from_request(self.nonce_request_via_login(&username, &password)) + .await; + } + + // Since the "cookies" part is out of our control, we need to verify that the nonce we got + // is actually for the user we expect. + if let Ok(nonce) = nonce.as_ref() { + let api_url = WpOrgSiteApiUrlResolver::new(self.details.api_root_url.clone()); + let auth = WpAuthentication::Nonce { + nonce: nonce.clone(), + }; + let users = UsersRequestExecutor::new( + Arc::new(api_url), + WpApiClientDelegate { + auth_provider: WpAuthenticationProvider::static_with_auth(auth).into(), + request_executor: self.request_executor.clone(), + middleware_pipeline: WpApiMiddlewarePipeline::default().into(), + app_notifier: Arc::new(EmptyAppNotifier), + }, + ); + let logged_in = users.retrieve_me_with_edit_context().await?.data.username; + if logged_in != username { + return Err(NonceRetrievalError::AlreadyLoggedIn { + username: logged_in, + }); + } + } + + nonce + } +} + +impl WpRestNonceRetrieval { + fn derived_login_url(&self) -> Url { + let mut url = self.details.parsed_site_url.inner.clone(); + url.path_segments_mut() + .expect("The site url is a full URL") + .push("wp-login.php"); + url + } + + fn derived_rest_nonce_url(&self) -> Url { + let mut url = self.derived_login_url(); + url.path_segments_mut() + .expect("The site url is a full URL") + .pop() // Remove the "wp-login.php" part + .push("wp-admin") + .push("admin-ajax.php"); + url.query_pairs_mut().append_pair("action", "rest-nonce"); + url + } + + fn nonce_request(&self) -> WpNetworkRequest { + WpNetworkRequest { + uuid: Uuid::new_v4().into(), + retry_count: 0, + method: RequestMethod::GET, + url: self.derived_rest_nonce_url().into(), + header_map: WpNetworkHeaderMap::default().into(), + body: None, + } + } + + fn nonce_request_via_login(&self, username: &str, password: &str) -> WpNetworkRequest { + let mut headers = http::HeaderMap::new(); + headers.insert( + http::header::CONTENT_TYPE, + HeaderValue::from_static("application/x-www-form-urlencoded"), + ); + + let body = serde_urlencoded::to_string([ + ["log", username], + ["pwd", password], + ["rememberme", "false"], + ["redirect_to", self.derived_rest_nonce_url().as_str()], + ]) + .map(|s| WpNetworkRequestBody::new(s.into_bytes())) + .ok(); + + WpNetworkRequest { + uuid: Uuid::new_v4().into(), + retry_count: 0, + method: RequestMethod::POST, + url: self.derived_login_url().into(), + header_map: WpNetworkHeaderMap::new(headers).into(), + body: body.map(Into::into), + } + } + + async fn nonce_from_request( + &self, + request: WpNetworkRequest, + ) -> Result { + let response = self + .request_executor + .execute(request.into()) + .await + .map_err(Into::::into)?; + + if response.status_code == 200 { + let body = response.body_as_string(); + if (2..=50).contains(&body.len()) { + return Ok(body); + } + } + Err(NonceRetrievalError::UnexpectedResponse { + status_code: response.status_code, + body: response.body_as_string(), + }) + } +} + +#[derive(Debug, thiserror::Error, uniffi::Error, WpDeriveLocalizable)] +pub enum NonceRetrievalError { + AlreadyLoggedIn { username: String }, + UnexpectedResponse { status_code: u16, body: String }, + ApiError { error: WpApiError }, +} + +impl WpSupportsLocalization for NonceRetrievalError { + fn message_bundle(&self) -> crate::MessageBundle<'_> { + match self { + NonceRetrievalError::AlreadyLoggedIn { username } => { + WpMessages::already_logged_in(username) + } + NonceRetrievalError::UnexpectedResponse { status_code, .. } => { + WpMessages::invalid_http_status_code(status_code) + } + NonceRetrievalError::ApiError { error } => error.message_bundle(), + } + } +} + +impl From for NonceRetrievalError { + fn from(error: WpApiError) -> Self { + NonceRetrievalError::ApiError { error } + } +} diff --git a/wp_api/src/request.rs b/wp_api/src/request.rs index 7d04e602f..05a3f59d8 100644 --- a/wp_api/src/request.rs +++ b/wp_api/src/request.rs @@ -151,9 +151,7 @@ impl InnerRequestBuilder { http::header::ACCEPT, HeaderValue::from_static(CONTENT_TYPE_JSON), ); - if let Some(hv) = self.auth_provider.auth_header_value() { - header_map.insert(http::header::AUTHORIZATION, hv); - } + self.auth_provider.insert_header(&mut header_map); header_map.into() } diff --git a/wp_api/src/reqwest_request_executor.rs b/wp_api/src/reqwest_request_executor.rs index 9978e1f36..f0c48e68d 100644 --- a/wp_api/src/reqwest_request_executor.rs +++ b/wp_api/src/reqwest_request_executor.rs @@ -46,6 +46,17 @@ impl ReqwestRequestExecutor { Duration::from_secs(DEFAULT_TIMEOUT), ) } + + pub fn new_with_cookie_store() -> Self { + Self { + client: reqwest::Client::builder() + .timeout(Duration::from_secs(DEFAULT_TIMEOUT)) + .use_rustls_tls() + .cookie_store(true) + .build() + .expect("We should be able to build the reqwest client with this configuration"), + } + } } impl ReqwestRequestExecutor { diff --git a/wp_api_integration_tests/src/lib.rs b/wp_api_integration_tests/src/lib.rs index 85a681ca4..4fb5a2602 100644 --- a/wp_api_integration_tests/src/lib.rs +++ b/wp_api_integration_tests/src/lib.rs @@ -1,14 +1,15 @@ use crate::prelude::*; use wp_api::{ + auth::CookiesNonceAuthenticationProvider, + login::login_client::WpLoginClient, nav_menus::NavMenuId, wp_com::{WpComBaseUrl, client::WpComApiClient, endpoint::WpComDotOrgApiUrlResolver}, }; +pub mod backend; pub mod mock; pub mod prelude; -pub mod backend; - // The first user is also the current user pub const FIRST_USER_ID: UserId = UserId(1); pub const FIRST_USER_EMAIL: &str = "test@example.com"; @@ -105,6 +106,41 @@ pub fn api_client_with_auth_provider(auth_provider: Arc WpApiClient { + let request_executor = Arc::new(ReqwestRequestExecutor::new_with_cookie_store()); + + let login_client = WpLoginClient::new( + request_executor.clone(), + Arc::new(WpApiMiddlewarePipeline::default()), + ); + let discovery_result = login_client + .api_discovery(TestCredentials::instance().site_url.to_string()) + .await; + let details = discovery_result + .combined_result() + .expect("API discovery should succeed"); + + let nonce_auth_provider = Arc::new(CookiesNonceAuthenticationProvider::new( + username, + password, + details.clone(), + request_executor.clone(), + )); + + WpApiClient::new( + test_site_api_url_resolver(), + WpApiClientDelegate { + auth_provider: Arc::new(WpAuthenticationProvider::dynamic(nonce_auth_provider)), + request_executor, + middleware_pipeline: Arc::new(WpApiMiddlewarePipeline::default()), + app_notifier: Arc::new(EmptyAppNotifier), + }, + ) +} + pub fn wp_com_client() -> WpComApiClient { WpComApiClient::new(WpApiClientDelegate { auth_provider: WpAuthenticationProvider::static_with_auth(WpAuthentication::Bearer { @@ -190,13 +226,3 @@ impl AssertResponse for Result { pub fn unwrapped_wp_gmt_date_time(s: &str) -> WpGmtDateTime { s.parse::().expect("Expected a valid date") } - -#[derive(Debug)] -pub struct EmptyAppNotifier; - -#[async_trait] -impl WpAppNotifier for EmptyAppNotifier { - async fn requested_with_invalid_authentication(&self, _request_url: String) { - // no-op - } -} diff --git a/wp_api_integration_tests/src/prelude.rs b/wp_api_integration_tests/src/prelude.rs index c193f29e1..a7517014e 100644 --- a/wp_api_integration_tests/src/prelude.rs +++ b/wp_api_integration_tests/src/prelude.rs @@ -1,14 +1,14 @@ pub use crate::{ AssertResponse, AssertWpError, CATEGORY_ID_48, CATEGORY_ID_59, CLASSIC_EDITOR_PLUGIN_SLUG, - COMMENT_ID_INVALID, EmptyAppNotifier, FIRST_COMMENT_ID, FIRST_POST_ID, FIRST_USER_EMAIL, - FIRST_USER_ID, HELLO_DOLLY_PLUGIN_SLUG, MEDIA_ID_611, MEDIA_ID_AUDIO, MEDIA_ID_IMAGE, - MEDIA_ID_VIDEO, MEDIA_TEST_FILE_CONTENT_TYPE, MEDIA_TEST_FILE_PATH, NAV_MENU_ID_179, - POST_ID_555, POST_ID_DRAFT, POST_ID_INVALID, POST_ID_NAV_MENUS_PARAM, - POST_TEMPLATE_SINGLE_WITH_SIDEBAR, SECOND_COMMENT_ID, SECOND_USER_EMAIL, SECOND_USER_ID, - SECOND_USER_SLUG, TAG_ID_100, TEMPLATE_TWENTY_TWENTY_FOUR_SINGLE, TERM_ID_INVALID, - THEME_TWENTY_TWENTY_FIVE, THEME_TWENTY_TWENTY_FOUR, THEME_TWENTY_TWENTY_THREE, USER_ID_INVALID, + COMMENT_ID_INVALID, FIRST_COMMENT_ID, FIRST_POST_ID, FIRST_USER_EMAIL, FIRST_USER_ID, + HELLO_DOLLY_PLUGIN_SLUG, MEDIA_ID_611, MEDIA_ID_AUDIO, MEDIA_ID_IMAGE, MEDIA_ID_VIDEO, + MEDIA_TEST_FILE_CONTENT_TYPE, MEDIA_TEST_FILE_PATH, NAV_MENU_ID_179, POST_ID_555, + POST_ID_DRAFT, POST_ID_INVALID, POST_ID_NAV_MENUS_PARAM, POST_TEMPLATE_SINGLE_WITH_SIDEBAR, + SECOND_COMMENT_ID, SECOND_USER_EMAIL, SECOND_USER_ID, SECOND_USER_SLUG, TAG_ID_100, + TEMPLATE_TWENTY_TWENTY_FOUR_SINGLE, TERM_ID_INVALID, THEME_TWENTY_TWENTY_FIVE, + THEME_TWENTY_TWENTY_FOUR, THEME_TWENTY_TWENTY_THREE, USER_ID_INVALID, WP_ORG_PLUGIN_SLUG_CLASSIC_WIDGETS, api_client, api_client_as_author, api_client_as_subscriber, - api_client_with_auth_provider, + api_client_with_account_credentials, api_client_with_auth_provider, backend::{Backend, RestoreServer}, mock::{MockExecutor, response_helpers}, test_site_api_url_resolver, test_site_url, unwrapped_wp_gmt_date_time, @@ -22,6 +22,6 @@ pub use serial_test::{parallel, serial}; pub use std::sync::Arc; pub use url::Url; pub use wp_api::{ - comments::CommentId, date::WpGmtDateTime, media::MediaId, posts::PostId, prelude::*, - reqwest_request_executor::ReqwestRequestExecutor, terms::TermId, users::UserId, + EmptyAppNotifier, comments::CommentId, date::WpGmtDateTime, media::MediaId, posts::PostId, + prelude::*, reqwest_request_executor::ReqwestRequestExecutor, terms::TermId, users::UserId, }; diff --git a/wp_api_integration_tests/tests/test_auth_provider_immut.rs b/wp_api_integration_tests/tests/test_auth_provider_immut.rs index 0ab195abd..9e2f514e8 100644 --- a/wp_api_integration_tests/tests/test_auth_provider_immut.rs +++ b/wp_api_integration_tests/tests/test_auth_provider_immut.rs @@ -151,3 +151,39 @@ async fn test_modifiable_auth_provider() { // FIRST_USER_ID is the current user's id assert_eq!(FIRST_USER_ID, user.id); } + +#[tokio::test] +#[parallel] +async fn test_cookies_nonce() { + let client = api_client_with_account_credentials( + TestCredentials::instance().admin_username.to_string(), + TestCredentials::instance() + .admin_account_password + .to_string(), + ) + .await; + + let user = client + .users() + .retrieve_me_with_edit_context() + .await + .assert_response() + .data; + assert_eq!(user.username, TestCredentials::instance().admin_username); +} + +#[tokio::test] +#[parallel] +async fn test_cookies_nonce_with_app_password() { + let client = api_client_with_account_credentials( + TestCredentials::instance().admin_username.to_string(), + TestCredentials::instance().admin_password.to_string(), + ) + .await; + + client + .users() + .retrieve_me_with_edit_context() + .await + .assert_wp_error(WpErrorCode::Unauthorized); +} diff --git a/wp_localization/localization/en-US/main.ftl b/wp_localization/localization/en-US/main.ftl index 1b4e43ebd..25e152963 100644 --- a/wp_localization/localization/en-US/main.ftl +++ b/wp_localization/localization/en-US/main.ftl @@ -70,3 +70,5 @@ application_passwords_not_supported = The site does not support Application Pass parse_api_root = Failed to parse the the site's WordPress REST API root response. parse_api_root_failure_reason_server_fatal_error = Your server encountered an unrecoverable error and couldn't process the request. Please check your server error logs for details. parse_api_root_failure_reason_wordfence_blocking_access = Wordfence is blocking access to the site's API. Please check your Wordfence configuration. + +already_logged_in = You are already logged in as {$username}.