PluginProbe
FlyWP Helper – Page Cache, Page Optimization, Emails for FlyWP Server Control Panel / 1.6.0
FlyWP Helper – Page Cache, Page Optimization, Emails for FlyWP Server Control Panel v1.6.0
1.7.1 1.7.0 1.6.0 1.5.2 trunk 0.1 0.2.0 0.2.1 0.3 0.3.1 0.3.2 0.3.3 0.3.4 0.4 0.4.1 0.4.2 0.4.3 1.0 1.1 1.2 1.3.1 1.4.0 1.4.1 1.5.0 1.5.1 All 26 releases
flywp / includes / MagicLoginToken.php

MagicLoginToken.php in FlyWP Helper – Page Cache, Page Optimization, Emails for FlyWP Server Control Panel 1.6.0, at includes/MagicLoginToken.php

183 lines 5.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FlyWP;
4
5 /**
6 * Verifier for the signed, single-use tokens the FlyWP control plane mints for magic login.
7 *
8 * The token format is a contract shared with the control plane, so it lives in one place with
9 * no WordPress dependency at all — it can be reasoned about, and tested, on its own.
10 *
11 * flywp1.<b64url(payload_json)>.<b64url(hmac_sha256)>
12 *
13 * Two properties of that shape are deliberate. The version prefix is part of the signed
14 * material, so the scheme cannot be downgraded by rewriting it. And the signature is verified
15 * against the payload bytes exactly as they arrived, never against a re-encoding of the decoded
16 * claims — the control plane runs PHP 8.3 and sites run anything from 7.4 up, and JSON
17 * canonicalisation is not worth betting a login on.
18 *
19 * @since 1.6.0
20 */
21 class MagicLoginToken {
22
23 /**
24 * Token version. Part of the signed material.
25 */
26 const VERSION = 'flywp1';
27
28 /**
29 * Longest token accepted, in bytes. Bounds the work done before the signature check.
30 */
31 const MAX_LENGTH = 4096;
32
33 /**
34 * Longest lifetime a token may claim for itself, in seconds.
35 */
36 const MAX_LIFETIME = 600;
37
38 /**
39 * Clock drift tolerated between the control plane and this site, in seconds.
40 */
41 const DEFAULT_SKEW = 60;
42
43 /**
44 * Verify a token and return the claims it carries.
45 *
46 * @param string $token Raw token as it arrived in the request.
47 * @param string $api_key This site's FLYWP_API_KEY.
48 * @param int $now Current unix timestamp.
49 * @param int $skew Clock drift to tolerate, in seconds.
50 *
51 * @return array|null The claims, or null when the token is not acceptable for any reason.
52 */
53 public static function parse( $token, $api_key, $now, $skew = self::DEFAULT_SKEW ) {
54 if ( ! is_string( $token ) || ! is_string( $api_key ) || $api_key === '' ) {
55 return null;
56 }
57
58 if ( $token === '' || strlen( $token ) > self::MAX_LENGTH ) {
59 return null;
60 }
61
62 $parts = explode( '.', $token );
63
64 if ( count( $parts ) !== 3 || $parts[0] !== self::VERSION ) {
65 return null;
66 }
67
68 $signature = self::base64url_decode( $parts[2] );
69
70 if ( $signature === null ) {
71 return null;
72 }
73
74 $expected = hash_hmac( 'sha256', $parts[0] . '.' . $parts[1], self::signing_key( $api_key ), true );
75
76 if ( ! hash_equals( $expected, $signature ) ) {
77 return null;
78 }
79
80 $payload = self::base64url_decode( $parts[1] );
81
82 if ( $payload === null ) {
83 return null;
84 }
85
86 $claims = json_decode( $payload, true );
87
88 if ( ! is_array( $claims ) || ! self::has_valid_claims( $claims ) ) {
89 return null;
90 }
91
92 if ( ! self::is_fresh( $claims, (int) $now, (int) $skew ) ) {
93 return null;
94 }
95
96 return $claims;
97 }
98
99 /**
100 * The key tokens are actually signed with, derived from the site's API key.
101 *
102 * The API key authenticates the REST API and the site's calls back to the control plane;
103 * deriving a subkey keeps a magic-login signature from being usable as anything else.
104 *
105 * @param string $api_key This site's FLYWP_API_KEY.
106 *
107 * @return string Raw binary key.
108 */
109 public static function signing_key( $api_key ) {
110 return hash_hmac( 'sha256', 'flywp-magic-login-v1', $api_key, true );
111 }
112
113 /**
114 * Whether every claim is present and of the right type.
115 *
116 * @param array $claims Decoded claims.
117 *
118 * @return bool
119 */
120 private static function has_valid_claims( array $claims ) {
121 foreach ( [ 'sub', 'sid', 'iat', 'exp', 'jti' ] as $claim ) {
122 if ( ! isset( $claims[ $claim ] ) ) {
123 return false;
124 }
125 }
126
127 if ( ! is_string( $claims['sub'] ) || $claims['sub'] === '' ) {
128 return false;
129 }
130
131 if ( ! is_int( $claims['sid'] ) || ! is_int( $claims['iat'] ) || ! is_int( $claims['exp'] ) ) {
132 return false;
133 }
134
135 return is_string( $claims['jti'] ) && preg_match( '/^[a-f0-9]{32}$/', $claims['jti'] ) === 1;
136 }
137
138 /**
139 * Whether the token is inside its stated lifetime, and that lifetime is plausible.
140 *
141 * @param array $claims Decoded claims.
142 * @param int $now Current unix timestamp.
143 * @param int $skew Clock drift to tolerate, in seconds.
144 *
145 * @return bool
146 */
147 private static function is_fresh( array $claims, $now, $skew ) {
148 $lifetime = $claims['exp'] - $claims['iat'];
149
150 if ( $lifetime <= 0 || $lifetime > self::MAX_LIFETIME ) {
151 return false;
152 }
153
154 // Both ends, not just expiry. Checking only the upper bound means a site whose clock runs
155 // behind accepts a token for as long as the drift lasts rather than for its lifetime: to
156 // such a site every token looks like it expires comfortably in the future. A site drifted
157 // further than the grace now refuses tokens outright, which is the safe direction to fail.
158 if ( $now + $skew < $claims['iat'] ) {
159 return false;
160 }
161
162 return $now <= ( $claims['exp'] + $skew );
163 }
164
165 /**
166 * Decode base64url, rejecting anything outside the alphabet.
167 *
168 * @param string $value Encoded segment.
169 *
170 * @return string|null Raw bytes, or null when the segment is not base64url.
171 */
172 private static function base64url_decode( $value ) {
173 if ( ! is_string( $value ) || preg_match( '/^[A-Za-z0-9\-_]+$/', $value ) !== 1 ) {
174 return null;
175 }
176
177 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
178 $decoded = base64_decode( strtr( $value, '-_', '+/' ), true );
179
180 return $decoded === false ? null : $decoded;
181 }
182 }
183