PluginProbe
FlyWP Helper – Page Cache, Page Optimization, Emails for FlyWP Server Control Panel / 1.7.0
FlyWP Helper – Page Cache, Page Optimization, Emails for FlyWP Server Control Panel v1.7.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.7.0, at includes/MagicLoginToken.php

187 lines 6.0 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 format is a contract shared with the control plane, so it lives in one place with no
9 * WordPress dependency and can be tested on its own.
10 *
11 * flywp-ed25519.<b64url(payload_json)>.<b64url(ed25519_signature)>
12 *
13 * Two rules when changing this: the prefix is part of the signed material, and the signature is
14 * verified against the payload bytes as received, never against a re-encoding of the claims.
15 *
16 * @since 1.7.0
17 */
18 class MagicLoginToken {
19
20 /**
21 * Token version. Part of the signed material.
22 */
23 const VERSION = 'flywp-ed25519';
24
25 /**
26 * Longest token accepted, in bytes. Bounds the work done before the signature check.
27 */
28 const MAX_LENGTH = 4096;
29
30 /**
31 * Longest lifetime a token may claim for itself, in seconds.
32 */
33 const MAX_LIFETIME = 600;
34
35 /**
36 * Clock drift tolerated between the control plane and this site, in seconds.
37 */
38 const DEFAULT_SKEW = 60;
39
40 /**
41 * Verify a token and return the claims it carries.
42 *
43 * @param string $token Raw token as it arrived in the request.
44 * @param string $public_key This site's FLYWP_LOGIN_PUBLIC_KEY (base64).
45 * @param int $now Current unix timestamp.
46 * @param int $skew Clock drift to tolerate, in seconds.
47 *
48 * @return array|null The claims, or null when the token is not acceptable for any reason.
49 */
50 public static function parse( $token, $public_key, $now, $skew = self::DEFAULT_SKEW ) {
51 if ( ! is_string( $token ) || $token === '' || strlen( $token ) > self::MAX_LENGTH ) {
52 return null;
53 }
54
55 $parts = explode( '.', $token );
56
57 if ( count( $parts ) !== 3 || $parts[0] !== self::VERSION ) {
58 return null;
59 }
60
61 // Verify before reading any claim.
62 if ( ! self::verify( $parts[0] . '.' . $parts[1], $parts[2], $public_key ) ) {
63 return null;
64 }
65
66 $payload = self::base64url_decode( $parts[1] );
67 $claims = $payload === null ? null : json_decode( $payload, true );
68
69 if ( ! is_array( $claims ) || ! self::has_valid_claims( $claims ) ) {
70 return null;
71 }
72
73 return self::is_fresh( $claims, (int) $now, (int) $skew ) ? $claims : null;
74 }
75
76 /**
77 * Whether this site's key vouches for the signature over $signed_material.
78 *
79 * Lengths are checked before the libsodium call, which throws on a wrong-size key or
80 * signature. Keep those checks.
81 *
82 * @param string $signed_material Bytes the signature is supposed to cover.
83 * @param string $encoded_signature Signature segment, base64url.
84 * @param string $public_key This site's FLYWP_LOGIN_PUBLIC_KEY (base64).
85 *
86 * @return bool
87 */
88 private static function verify( $signed_material, $encoded_signature, $public_key ) {
89 // A host can be built without sodium; refuse rather than skip the check.
90 if ( ! function_exists( 'sodium_crypto_sign_verify_detached' ) ) {
91 return false;
92 }
93
94 if ( ! is_string( $public_key ) || $public_key === '' ) {
95 return false;
96 }
97
98 $signature = self::base64url_decode( $encoded_signature );
99
100 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
101 $raw_public_key = base64_decode( $public_key, true );
102
103 if ( $signature === null || strlen( $signature ) !== SODIUM_CRYPTO_SIGN_BYTES ) {
104 return false;
105 }
106
107 if ( $raw_public_key === false || strlen( $raw_public_key ) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES ) {
108 return false;
109 }
110
111 return sodium_crypto_sign_verify_detached( $signature, $signed_material, $raw_public_key );
112 }
113
114 /**
115 * Whether every claim is present and of the right type.
116 *
117 * @param array $claims Decoded claims.
118 *
119 * @return bool
120 */
121 private static function has_valid_claims( array $claims ) {
122 foreach ( [ 'sub', 'sid', 'iat', 'exp', 'jti', 'flywp_user_id' ] as $claim ) {
123 if ( ! array_key_exists( $claim, $claims ) ) {
124 return false;
125 }
126 }
127
128 if ( ! is_string( $claims['sub'] ) || $claims['sub'] === '' ) {
129 return false;
130 }
131
132 if ( ! is_int( $claims['sid'] ) || ! is_int( $claims['iat'] ) || ! is_int( $claims['exp'] ) || ! is_int( $claims['flywp_user_id'] ) ) {
133 return false;
134 }
135
136 return is_string( $claims['jti'] ) && preg_match( '/^[a-f0-9]{32}$/', $claims['jti'] ) === 1;
137 }
138
139 /**
140 * Whether the token is inside its stated lifetime, and that lifetime is plausible.
141 *
142 * @param array $claims Decoded claims.
143 * @param int $now Current unix timestamp.
144 * @param int $skew Clock drift to tolerate, in seconds.
145 *
146 * @return bool
147 */
148 private static function is_fresh( array $claims, $now, $skew ) {
149 $lifetime = $claims['exp'] - $claims['iat'];
150
151 if ( $lifetime <= 0 || $lifetime > self::MAX_LIFETIME ) {
152 return false;
153 }
154
155 if ( $now + $skew < $claims['iat'] ) {
156 return false;
157 }
158
159 return $now <= ( $claims['exp'] + $skew );
160 }
161
162 /**
163 * Decode base64url, rejecting anything outside the alphabet.
164 *
165 * @param string $value Encoded segment.
166 *
167 * @return string|null Raw bytes, or null when the segment is not base64url.
168 */
169 private static function base64url_decode( $value ) {
170 if ( ! is_string( $value ) || preg_match( '/^[A-Za-z0-9\-_]+$/', $value ) !== 1 ) {
171 return null;
172 }
173
174 $padded = strtr( $value, '-_', '+/' );
175 $remainder = strlen( $padded ) % 4;
176
177 if ( $remainder !== 0 ) {
178 $padded .= str_repeat( '=', 4 - $remainder );
179 }
180
181 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
182 $decoded = base64_decode( $padded, true );
183
184 return $decoded === false ? null : $decoded;
185 }
186 }
187