Skip to content

Commit 75ca755

Browse files
Support mobile access to playground site (#73)
## Motivation for the change, related issues This PR updates the private web app to fall back to wpcom OAuth when a request is not A8C-proxied. This way, users connecting via mobile devices can still access the Playground instance. Closes #53 ## Implementation details We add a new script `site-privacy.php` which helps enforce privacy by doing proxy and oauth checks. This enforcement only works for requests that are served via PHP, but that is sufficient for the private Playground instance. We are serving the app for internal purposes, but there is nothing secret about the app. ## Testing Instructions (or ideally a Blueprint) - Temporarily disable the branch and user protections in the deployment workflow - Run it to deploy the site - Confirm access is allowed for: - Users connecting via A8C proxy - Automatticians signing in via OAuth - Confirm Access is denied for a non-Automattic test user who attempts to sign in via OAuth
1 parent b0e5ac2 commit 75ca755

File tree

2 files changed

+265
-7
lines changed

2 files changed

+265
-7
lines changed

.github/workflows/deploy-private-website.yml

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,27 +45,30 @@ jobs:
4545
echo "${{ secrets.DEPLOY_WEBSITE_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
4646
chmod 0600 ~/.ssh/*
4747
48+
# Add site privacy script to the website build
49+
cp packages/playground/website-deployment/a8c/site-privacy.php \
50+
dist/packages/playground/wasm-wordpress-net/
51+
4852
# Website files
4953
rsync -avz -e "ssh -i ~/.ssh/id_ed25519" --delete \
5054
dist/packages/playground/wasm-wordpress-net/ \
5155
${{ secrets.DEPLOY_WEBSITE_TARGET_USER }}@${{ secrets.DEPLOY_WEBSITE_TARGET_HOST }}:'~/updated-playground-files'
5256
57+
# Include MIME types for use with PHP-served files
58+
cp packages/php-wasm/universal/src/lib/mime-types.json \
59+
packages/playground/website-deployment/
60+
5361
# Host-specific deployment scripts and server config
5462
rsync -avz -e "ssh -i ~/.ssh/id_ed25519" --delete \
5563
packages/playground/website-deployment/ \
5664
${{ secrets.DEPLOY_WEBSITE_TARGET_USER }}@${{ secrets.DEPLOY_WEBSITE_TARGET_HOST }}:'~/website-deployment'
5765
58-
# Copy MIME types for use with PHP-served files
59-
rsync -avz -e "ssh -i ~/.ssh/id_ed25519" \
60-
packages/php-wasm/universal/src/lib/mime-types.json \
61-
${{ secrets.DEPLOY_WEBSITE_TARGET_USER }}@${{ secrets.DEPLOY_WEBSITE_TARGET_HOST }}:'~/website-deployment/'
62-
6366
# Apply update
6467
ssh -i ~/.ssh/id_ed25519 \
6568
${{ secrets.DEPLOY_WEBSITE_TARGET_USER }}@${{ secrets.DEPLOY_WEBSITE_TARGET_HOST }} \
6669
-tt '~/website-deployment/apply-update.sh'
6770
68-
# Require A8C proxy for private web app
71+
# Enforce privacy for web app
6972
ssh -i ~/.ssh/id_ed25519 \
7073
${{ secrets.DEPLOY_WEBSITE_TARGET_USER }}@${{ secrets.DEPLOY_WEBSITE_TARGET_HOST }} \
71-
-tt 'sed -i "2i if ( \"cli\" !== php_sapi_name() && empty( \$_SERVER[\"A8C_PROXIED_REQUEST\"] ) ) { http_response_code( 401 ); echo \"Unauthorized. Please proxy.\"; die(); }" ~/htdocs/custom-redirects.php'
74+
-tt 'sed -i "2i if ( \"cli\" !== php_sapi_name() ) { require_once __DIR__ . \"/site-privacy.php\"; Site_Privacy::enforce(); }" ~/htdocs/custom-redirects.php'
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
<?php
2+
3+
class Site_Privacy {
4+
const OAUTH_PATH = '/_oauth_access';
5+
6+
public static function enforce() {
7+
$privacy = new Site_Privacy();
8+
9+
if ( $privacy->is_request_a8c_proxied() ) {
10+
// The user is A8C-proxied. Let the request pass through.
11+
return;
12+
}
13+
14+
$privacy->assert_secrets_present_for_oauth();
15+
if ( $privacy->is_request_authorized_via_oauth() ) {
16+
// The user is authorized. Let the request pass through.
17+
return;
18+
}
19+
20+
$should_start_oauth = ! str_starts_with(
21+
$_SERVER["REQUEST_URI"],
22+
self::OAUTH_PATH,
23+
);
24+
if ( $should_start_oauth ) {
25+
$privacy->start_oauth( $_SERVER['REQUEST_URI'] );
26+
} else {
27+
$privacy->complete_oauth();
28+
}
29+
die();
30+
}
31+
32+
private $secrets = null;
33+
function __construct() {
34+
__atomic_env_define( 'DB_PASSWORD' );
35+
$this->secrets = new Atomic_Persistent_Data;
36+
}
37+
38+
public function is_request_a8c_proxied() {
39+
return ! empty( $_SERVER["A8C_PROXIED_REQUEST"] );
40+
}
41+
42+
public function assert_secrets_present_for_oauth() {
43+
$secrets_present_for_oauth = isset(
44+
$this->secrets->WPCOM_OAUTH_CLIENT_ID,
45+
$this->secrets->WPCOM_OAUTH_CLIENT_SECRET,
46+
$this->secrets->WPCOM_OAUTH_USER_SALT,
47+
$this->secrets->WPCOM_OAUTH_USER_HMAC_SECRET,
48+
);
49+
50+
if ( ! $secrets_present_for_oauth ) {
51+
// Deny access because OAuth is not configured.
52+
http_response_code( 401 );
53+
echo 'Unauthorized. Please proxy.';
54+
die();
55+
}
56+
}
57+
58+
public function is_request_authorized_via_oauth() {
59+
if ( ! isset( $_COOKIE['AUTH_HASH'], $_COOKIE['AUTH_HASH_HMAC'] ) ) {
60+
return false;
61+
}
62+
63+
if ( $_COOKIE['AUTH_HASH_HMAC'] !== hash_hmac(
64+
'sha256',
65+
$_COOKIE['AUTH_HASH'],
66+
$this->secrets->WPCOM_OAUTH_USER_HMAC_SECRET,
67+
) ) {
68+
return false;
69+
}
70+
71+
$parts = explode( ',', $_COOKIE['AUTH_HASH'] );
72+
$expiry = $parts[1] ?? 0;
73+
74+
// Enforce expiry so that auth cookie values cannot be used indefinitely
75+
return $expiry > time();
76+
}
77+
78+
public function start_oauth( $target_path ) {
79+
$wpcom_oauth_url = 'https://public-api.wordpress.com/oauth2/authorize?' .
80+
'client_id=113927' .
81+
'&response_type=code' .
82+
'&blog_id=0' .
83+
'&redirect_uri=' . urlencode( $this->get_oauth_redirect_uri() ) .
84+
'&state=' . urlencode( $target_path );
85+
86+
http_response_code( 302 );
87+
header( "Location: $wpcom_oauth_url" );
88+
89+
die();
90+
}
91+
92+
public function complete_oauth() {
93+
if ( empty( $_GET['code'] ) ) {
94+
http_response_code( 401 );
95+
echo 'Access denied. Unable to complete OAuth without code.';
96+
die();
97+
}
98+
99+
$target_path = '/';
100+
if ( isset( $_GET['state'] ) ) {
101+
$target_path = $_GET['state'];
102+
}
103+
104+
$access_token = $this->request_access_token(
105+
$_GET['code'],
106+
$target_path
107+
);
108+
109+
$is_automattician = $this->request_is_automattician( $access_token );
110+
if ( ! $is_automattician ) {
111+
http_response_code( 401 );
112+
echo 'User is unauthorized';
113+
die();
114+
}
115+
116+
$user = $this->request_user_data( $access_token );
117+
$this->complete_auth_and_redirect(
118+
$user->username,
119+
$target_path,
120+
);
121+
die();
122+
}
123+
124+
private function get_oauth_redirect_uri() {
125+
return "https://{$_SERVER['HTTP_HOST']}" . self::OAUTH_PATH;
126+
}
127+
128+
private function request_access_token($code, $target_path) {
129+
$curl = curl_init( 'https://public-api.wordpress.com/oauth2/token' );
130+
curl_setopt( $curl, CURLOPT_POST, true );
131+
curl_setopt( $curl, CURLOPT_POSTFIELDS, array(
132+
'client_id' => $this->secrets->WPCOM_OAUTH_CLIENT_ID,
133+
'client_secret' => $this->secrets->WPCOM_OAUTH_CLIENT_SECRET,
134+
'code' => $_GET['code'],
135+
'grant_type' => 'authorization_code',
136+
'redirect_uri' => $this->get_oauth_redirect_uri(),
137+
'state' => $target_path,
138+
) );
139+
curl_setopt( $curl, CURLOPT_RETURNTRANSFER, 1 );
140+
$raw_response = curl_exec( $curl );
141+
$response_status = curl_getinfo( $curl, CURLINFO_HTTP_CODE );
142+
if ( $response_status != 200 ) {
143+
http_response_code( 401 );
144+
echo 'HTTP error during auth';
145+
die();
146+
}
147+
148+
$response = json_decode( $raw_response );
149+
if ( isset( $response->error ) ) {
150+
http_response_code( 401 );
151+
echo "Auth error: {$response->error}";
152+
die();
153+
}
154+
155+
return $response->access_token;
156+
}
157+
158+
private function request_is_automattician( $access_token ) {
159+
$curl = curl_init( 'https://public-api.wordpress.com/rest/v1.1/internal/automattician' );
160+
curl_setopt( $curl, CURLOPT_HTTPHEADER, array( 'Authorization: Bearer ' . $access_token ) );
161+
curl_setopt( $curl, CURLOPT_RETURNTRANSFER, 1 );
162+
$raw_response = curl_exec( $curl );
163+
$response_status = curl_getinfo( $curl, CURLINFO_HTTP_CODE );
164+
165+
// In testing, non-automatticians received a 403 response from this endpoint.
166+
if ( $response_status == 403 ) {
167+
return false;
168+
} elseif ( $response_status != 200 ) {
169+
http_response_code( 401 );
170+
echo 'HTTP error during user query';
171+
die();
172+
}
173+
174+
$response = json_decode( $raw_response );
175+
return isset( $response->is_automattician ) && $response->is_automattician;
176+
}
177+
178+
private function request_user_data( $access_token ) {
179+
$curl = curl_init( 'https://public-api.wordpress.com/rest/v1.1/me' );
180+
curl_setopt( $curl, CURLOPT_HTTPHEADER, array( 'Authorization: Bearer ' . $access_token ) );
181+
curl_setopt( $curl, CURLOPT_RETURNTRANSFER, 1 );
182+
$raw_me_response = curl_exec( $curl );
183+
$me_response_status = curl_getinfo( $curl, CURLINFO_HTTP_CODE );
184+
if ( $me_response_status != 200 ) {
185+
http_response_code( 401 );
186+
echo 'HTTP error during user lookup';
187+
die();
188+
}
189+
190+
return json_decode( $raw_me_response );
191+
}
192+
193+
private function complete_auth_and_redirect( $username, $target_path ) {
194+
$salt = $this->secrets->WPCOM_OAUTH_USER_SALT;
195+
// Note: We only use the username as some unique user-specific value.
196+
// We salt because there is no harm in additional discretion
197+
// in case the cookies leak to a Playground on the client side
198+
// (which they should not as long as the cookies are http-only).
199+
$salted_username = $username . $salt;
200+
if (
201+
! is_string( $username ) || empty( $username ) ||
202+
! is_string( $salt ) || empty( $salt )
203+
) {
204+
http_response_code( 401 );
205+
echo 'Unable to remember username';
206+
die();
207+
}
208+
209+
$thirty_days_in_seconds = 30 * 24 * 60 * 60;
210+
$expiry = time() + $thirty_days_in_seconds;
211+
212+
$username_hash = hash('sha256', $salted_username);
213+
// Include expiry so it can be validated and enforced.
214+
// Otherwise, clients could provide use with an auth
215+
// cookie value that is valid indefinitely.
216+
$username_hash_with_expiry = "$username_hash,$expiry";
217+
218+
$hmac_secret = $this->secrets->WPCOM_OAUTH_USER_HMAC_SECRET;
219+
if ( empty( $hmac_secret ) ) {
220+
http_response_code( 401 );
221+
echo 'Unable to generate HMAC';
222+
die();
223+
}
224+
225+
http_response_code( 302 );
226+
227+
setcookie(
228+
'AUTH_HASH',
229+
$username_hash_with_expiry,
230+
$expiry,
231+
'/',
232+
$_SERVER['HTTP_HOST'],
233+
true, /* secure */
234+
true, /* http-only */
235+
);
236+
237+
$username_hash_hmac = hash_hmac(
238+
'sha256',
239+
$username_hash_with_expiry,
240+
$hmac_secret
241+
);
242+
setcookie(
243+
'AUTH_HASH_HMAC',
244+
$username_hash_hmac,
245+
$expiry,
246+
'/',
247+
$_SERVER['HTTP_HOST'],
248+
true, /* secure */
249+
true, /* http-only */
250+
);
251+
252+
header( "Location: {$target_path}" );
253+
die();
254+
}
255+
}

0 commit comments

Comments
 (0)