Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
10.90% covered (danger)
10.90%
17 / 156
10.00% covered (danger)
10.00%
1 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Auth
10.90% covered (danger)
10.90%
17 / 156
10.00% covered (danger)
10.00%
1 / 10
2961.55
0.00% covered (danger)
0.00%
0 / 1
 __construct
33.33% covered (danger)
33.33%
8 / 24
0.00% covered (danger)
0.00%
0 / 1
39.63
 __get
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
5.26
 isUsingBasicAuth
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 authorizeInternal
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
72
 authorize
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 authorise
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getAuthHeaders
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
2.15
 getTokenDetails
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 requestToken
0.00% covered (danger)
0.00%
0 / 58
0.00% covered (danger)
0.00%
0 / 1
342
 createTokenRequest
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
342
1<?php
2namespace Ably;
3
4use Ably\AblyRest;
5use Ably\Log;
6use Ably\Models\AuthOptions;
7use Ably\Models\ClientOptions;
8use Ably\Models\TokenDetails;
9use Ably\Models\TokenParams;
10use Ably\Models\TokenRequest;
11use Ably\Exceptions\AblyException;
12
13/**
14 * Provides authentification methods for AblyRest instances
15 * @property-read string|null $clientId ClientId currently in use. Null if not authenticated yet or when using anonymous auth.
16 */
17class Auth {
18    protected $defaultAuthOptions;
19    protected $defaultTokenParams;
20    protected $defaultAuthorizeAuthOptions = [];
21    protected $defaultAuthorizeTokenParams = [];
22    protected $basicAuth;
23    protected $tokenDetails;
24    protected $ably;
25    const TOKEN_EXPIRY_MARGIN = 15000; // a token is considered expired a bit earlier to prevent race conditions
26
27    public function __construct( AblyRest $ably, ClientOptions $options ) {
28        $this->defaultAuthOptions = new AuthOptions($options);
29        $this->defaultTokenParams = $options->defaultTokenParams;
30        $this->ably = $ably;
31
32        if ( empty( $this->defaultAuthOptions->useTokenAuth ) && $this->defaultAuthOptions->key && empty( $this->defaultAuthOptions->clientId ) ) {
33            $this->basicAuth = true;
34            Log::d( 'Auth: anonymous, using basic auth' );
35
36            if ( !$options->tls ) {
37                log::e( 'Auth: trying to use basic key auth over insecure connection' );
38                throw new AblyException ( 'Trying to use basic key auth over insecure connection', 40103, 401 );
39            }
40            return;
41        }
42
43        $this->basicAuth = false;
44
45        if(!empty( $this->defaultAuthOptions->authCallback )) {
46            Log::d( 'Auth: using token auth with authCallback' );
47        } else if(!empty( $this->defaultAuthOptions->authUrl )) {
48            Log::d( 'Auth: using token auth with authUrl' );
49        } else if(!empty( $this->defaultAuthOptions->key )) {
50            Log::d( 'Auth: using token auth with client-side signing' );
51        } else if(!empty( $this->defaultAuthOptions->tokenDetails )) {
52            Log::d( 'Auth: using token auth with supplied token only' );
53        } else {
54            Log::e( 'Auth: no authentication parameters supplied' );
55            throw new AblyException ( 'No authentication parameters supplied', 40103, 401 );
56        }
57
58        $this->tokenDetails = $this->defaultAuthOptions->tokenDetails;
59
60        if ( $this->defaultAuthOptions->clientId == '*' ) {
61            throw new AblyException ( 'Instantiating AblyRest with a wildcard clientId (`*`) not allowed.', 40003, 400 );
62        }
63    }
64
65    /**
66     * Magic getter for the $clientId property
67     */
68    public function __get( $name ) {
69        if ($name == 'clientId') {
70            if ( empty( $this->tokenDetails ) ) {
71                if ( !empty( $this->defaultAuthOptions->clientId ) ) {
72                    return $this->defaultAuthOptions->clientId;
73                }
74            } else {
75                return $this->tokenDetails->clientId;
76            }
77
78            return null;
79        }
80
81        throw new AblyException( 'Undefined property: '.__CLASS__.'::'.$name );
82    }
83
84    public function isUsingBasicAuth() {
85        return $this->basicAuth;
86    }
87
88
89    public function authorizeInternal( $tokenParams = [], $authOptions = [], $force = true ) {
90
91        if ( !empty( $tokenParams ) ) {
92            $tokenParamsCopy = $tokenParams;
93            if ( isset( $tokenParamsCopy['timestamp'] ) ) unset( $tokenParamsCopy['timestamp'] );
94
95            $this->defaultAuthorizeTokenParams = array_merge( $this->defaultAuthorizeTokenParams, $tokenParamsCopy );
96        }
97        if ( !empty( $authOptions ) ) {
98            $authOptionsCopy = $authOptions;
99
100            $this->defaultAuthorizeAuthOptions = array_merge( $this->defaultAuthorizeAuthOptions, $authOptionsCopy );
101        }
102
103        if ( !$force && !empty( $this->tokenDetails ) ) {
104            if ( empty( $this->tokenDetails->expires ) ) {
105                // using cached token
106                Log::d( 'Auth::authorize: using cached token, unknown expiration time' );
107                return $this->tokenDetails;
108            } else if ( $this->tokenDetails->expires - self::TOKEN_EXPIRY_MARGIN > $this->ably->systemTime() ) {
109                // using cached token
110                Log::d( 'Auth::authorize: using cached token, expires on ' . date( 'Y-m-d H:i:s', $this->tokenDetails->expires / 1000 ) );
111                return $this->tokenDetails;
112            }
113        }
114
115        $tokenParamsWithDefaults = array_merge( $this->defaultAuthorizeTokenParams, $tokenParams );
116        $authOptionsWithDefaults = array_merge( $this->defaultAuthorizeAuthOptions, $authOptions );
117
118        Log::d( 'Auth::authorize: requesting new token' );
119        $this->tokenDetails = $this->requestToken( $tokenParamsWithDefaults, $authOptionsWithDefaults );
120        $this->basicAuth = false;
121
122        return $this->tokenDetails;
123    }
124
125    /**
126     * Ensures that a valid token is present for the library instance. This will always request a new token.
127     * In the event that a new token request is made, the specified options are used.
128     * If not already using token based auth, this will enable it.
129     * Stores the AuthOptions and TokenParams arguments as defaults for subsequent authorisations.
130     * @param array|null $tokenParams Requested token parameters
131     * @param array|null $authOptions Overridable auth options, if you don't wish to use the default ones
132     * @return \Ably\Models\TokenDetails The new token
133     */
134    public function authorize( $tokenParams = [], $authOptions = [] ) {
135        return $this->authorizeInternal( $tokenParams, $authOptions );
136    }
137
138    /**
139     * @deprecated 1.0 Please use `authorize` instead
140     */
141    public function authorise( $tokenParams = [], $authOptions = [] ) {
142        Log::w( 'Auth::authorise is deprecated, please use Auth::authorize instead');
143
144        return $this->authorizeInternal( $tokenParams, $authOptions );
145    }
146
147    /**
148     * Get HTTP headers with authentication data
149     * Automatically attempts to authorize token requests
150     * @return Array Array of HTTP headers containing an `Authorization` header
151     */
152    public function getAuthHeaders() {
153        $headers = [];
154        if ( $this->isUsingBasicAuth() ) {
155            $headers[] = 'Authorization: Basic ' . base64_encode( $this->defaultAuthOptions->key );
156        } else {
157            $this->authorizeInternal( [], [], $force = false ); // authorize only if necessary
158            $headers[] = 'Authorization: Bearer '. base64_encode( $this->tokenDetails->token );
159        }
160
161        return $headers;
162    }
163
164    /**
165     * @return \Ably\Models\TokenDetails Token currently in use
166    */
167    public function getTokenDetails() {
168        return $this->tokenDetails;
169    }
170
171    /**
172     * Request a new token.
173     * @param array|null $tokenParams Requested token parameters
174     * @param array|null $authOptions Overridable auth options, if you don't wish to use the default ones
175     * @param \Ably\Models\ClientOptions|array $options
176     * @throws \Ably\Exceptions\AblyException
177     * @return \Ably\Models\TokenDetails The new token
178     */
179    public function requestToken( $tokenParams = [], $authOptions = [] ) {
180        // token clientId priority:
181        // $tokenParams->clientId overrides $authOptions->tokenId overrides $this->defaultAuthOptions->clientId overrides $this->defaultTokenParams->clientId
182        $tokenClientId = $this->defaultTokenParams->clientId;
183        if ( !empty( $this->defaultAuthOptions->clientId ) ) $tokenClientId = $this->defaultAuthOptions->clientId;
184        // provided authOptions may override clientId, even with a null value
185        if ( array_key_exists( 'clientId', $authOptions ) ) $tokenClientId = $authOptions['clientId'];
186        // provided tokenParams may override clientId, even with a null value
187        if ( array_key_exists( 'clientId', $tokenParams ) ) $tokenClientId = $tokenParams['clientId'];
188
189        // merge provided auth options with defaults
190        $authOptionsMerged = new AuthOptions( array_merge( $this->defaultAuthOptions->toArray(), $authOptions ) );
191        $tokenParamsMerged = new TokenParams( array_merge( $this->defaultTokenParams->toArray(), $tokenParams ) );
192
193        $tokenParamsMerged->clientId = $tokenClientId;
194
195        // get a signed token request
196        $signedTokenRequest = null;
197        if ( !empty( $authOptionsMerged->authCallback ) ) {
198            Log::d( 'Auth::requestToken:', 'using token auth with auth_callback' );
199
200            $callback = $authOptionsMerged->authCallback;
201            $data = $callback($tokenParamsMerged);
202
203            // returned data can be either a signed TokenRequest or TokenDetails or just a token string
204            if ( is_a( $data, '\Ably\Models\TokenRequest' ) ) {
205                $signedTokenRequest = $data;
206            } else if ( is_a( $data, '\Ably\Models\TokenDetails' ) ) {
207                return $data;
208            } else if ( is_string( $data ) ) {
209                return new TokenDetails( $data );
210            } else {
211                Log::e( 'Auth::requestToken:', 'Invalid response from authCallback, expecting signed TokenRequest or TokenDetails or a token string' );
212                throw new AblyException( 'Invalid response from authCallback' );
213            }
214        } elseif ( !empty( $authOptionsMerged->authUrl ) ) {
215            Log::d( 'Auth::requestToken:', 'using token auth with auth_url' );
216
217            $data = $this->ably->http->request(
218                $authOptionsMerged->authMethod,
219                $authOptionsMerged->authUrl,
220                $authOptionsMerged->authHeaders ? : [],
221                array_merge( $authOptionsMerged->authParams ? : [], $tokenParamsMerged->toArray() )
222            );
223
224            $data = $data['body'];
225
226            if ( is_string( $data ) ) {
227                return new TokenDetails( $data ); // assuming it's a token string
228            } else if ( is_object( $data ) ) {
229                if ( !empty( $data->issued ) ) { // assuming it's a token
230                    return new TokenDetails( $data );
231                } else if ( !empty( $data->mac ) ) { // assuming it's a signed token request
232                    $signedTokenRequest = new TokenRequest( $data );
233                } else {
234                    Log::e( 'Auth::requestToken:', 'Invalid response from authURL, expecting JSON representation of signed TokenRequest or TokenDetails' );
235                    throw new AblyException( 'Invalid response from authURL' );
236                }
237            } else {
238                Log::e( 'Auth::requestToken:', 'Invalid response from authURL, expecting token string or JSON representation of signed TokenRequest or TokenDetails' );
239                throw new AblyException( 'Invalid response from authURL' );
240            }
241        } elseif ( !empty( $authOptionsMerged->key ) ) {
242            Log::d( 'Auth::requestToken:', 'using token auth with client-side signing' );
243            $signedTokenRequest = $this->createTokenRequest( $tokenParams, $authOptions );
244        } else {
245            Log::e( 'Auth::requestToken:', 'Unable to request a Token, auth options don\'t provide means to do so' );
246            throw new AblyException( 'Unable to request a Token, auth options don\'t provide means to do so', 40101, 401 );
247        }
248
249        // do the request
250
251        $keyName = $signedTokenRequest->keyName;
252
253        if ( empty( $keyName ) ) {
254            throw new AblyException( 'No keyName specified in the TokenRequest' );
255        }
256
257        $res = $this->ably->post(
258            "/keys/{$keyName}/requestToken",
259            $headers = [],
260            $params = json_encode( $signedTokenRequest->toArray() ),
261            $returnHeaders = false,
262            $authHeaders = false
263        );
264
265        if ( empty( $res->token ) ) { // just in case.. an AblyRequestException should be thrown on the previous step with a 4XX error code on failure
266            throw new AblyException( 'Failed to get a token', 40100, 401 );
267        }
268
269        return new TokenDetails( $res );
270    }
271
272    /**
273     * Create a signed token request based on known credentials
274     * and the given token params. This would typically be used if creating
275     * signed requests for submission by another client.
276     * @param \Ably\Models\TokenParams $tokenParams
277     * @param \Ably\Models\AuthOptions $authOptions
278     * @return \Ably\Models\TokenRequest A signed token request
279     */
280    public function createTokenRequest( $tokenParams = [], $authOptions = [] ) {
281        $tokenClientId = $this->defaultTokenParams->clientId;
282        if ( !empty( $this->defaultAuthOptions->clientId ) ) $tokenClientId = $this->defaultAuthOptions->clientId;
283        if ( array_key_exists( 'clientId', $authOptions ) ) $tokenClientId = $authOptions['clientId'];
284        if ( array_key_exists( 'clientId', $tokenParams ) ) $tokenClientId = $tokenParams['clientId'];
285
286        $authOptions = new AuthOptions( array_merge( $this->defaultAuthOptions->toArray(), $authOptions ) );
287        $tokenParams = new TokenParams( array_merge( $this->defaultTokenParams->toArray(), $tokenParams ) );
288        $tokenParams->clientId = $tokenClientId;
289
290        $keyParts = explode( ':', $authOptions->key );
291
292        if ( count( $keyParts ) != 2 ) {
293            Log::e( 'Auth::createTokenRequest', "Can't create signed token request, invalid key specified" );
294            throw new AblyException( 'Invalid key specified', 40101, 401 );
295        }
296
297        $keyName   = $keyParts[0];
298        $keySecret = $keyParts[1];
299
300        $tokenRequest = new TokenRequest( $tokenParams );
301
302        if ( !empty( $tokenRequest->keyName ) && $tokenRequest->keyName != $keyName ) {
303            throw new AblyException( 'Incompatible keys specified', 40102, 401 );
304        } else {
305            $tokenRequest->keyName = $keyName;
306        }
307
308        if ( $authOptions->queryTime ) {
309            $tokenRequest->timestamp = sprintf ( "%.0f", $this->ably->time() );
310        } else if ( empty( $tokenRequest->timestamp ) ) {
311            $tokenRequest->timestamp = sprintf ( "%.0f", $this->ably->systemTime() );
312        }
313        // note: sprintf converts floating point numbers to plain integers (without scientific notation)
314        // regardless of the "precision" php.ini setting
315
316        if ( empty( $tokenRequest->clientId ) ) {
317            $tokenRequest->clientId = $authOptions->clientId;
318        }
319
320        if ( empty( $tokenRequest->nonce ) ) {
321            $tokenRequest->nonce = md5( microtime( true ) . mt_rand() );
322        }
323
324        $signText = implode( "\n", [
325            empty( $tokenRequest->keyName )    ? '' : $tokenRequest->keyName,
326            empty( $tokenRequest->ttl )        ? '' : $tokenRequest->ttl,
327            empty( $tokenRequest->capability ) ? '' : $tokenRequest->capability,
328            empty( $tokenRequest->clientId )   ? '' : $tokenRequest->clientId,
329            empty( $tokenRequest->timestamp )  ? '' : $tokenRequest->timestamp,
330            empty( $tokenRequest->nonce )      ? '' : $tokenRequest->nonce,
331        ] ) . "\n";
332
333
334        if ( empty( $tokenRequest->mac ) ) {
335            $tokenRequest->mac = base64_encode( hash_hmac( 'sha256', $signText, $keySecret, true ) );
336        }
337
338        return $tokenRequest;
339    }
340}