]>
Commit | Line | Data |
---|---|---|
fada8272 JJ |
1 | import base64 |
2 | import binascii | |
3 | import datetime | |
4 | import hashlib | |
5 | import hmac | |
6 | import json | |
7 | import os | |
8 | ||
9 | from .common import InfoExtractor | |
10 | from ..utils import ( | |
11 | ExtractorError, | |
12 | traverse_obj, | |
13 | unescapeHTML, | |
14 | ) | |
15 | ||
16 | ||
17 | class GoPlayIE(InfoExtractor): | |
18 | _VALID_URL = r'https?://(www\.)?goplay\.be/video/([^/]+/[^/]+/|)(?P<display_id>[^/#]+)' | |
19 | ||
20 | _NETRC_MACHINE = 'goplay' | |
21 | ||
22 | _TESTS = [{ | |
23 | 'url': 'https://www.goplay.be/video/de-container-cup/de-container-cup-s3/de-container-cup-s3-aflevering-2#autoplay', | |
24 | 'info_dict': { | |
25 | 'id': '9c4214b8-e55d-4e4b-a446-f015f6c6f811', | |
26 | 'ext': 'mp4', | |
27 | 'title': 'S3 - Aflevering 2', | |
28 | 'series': 'De Container Cup', | |
29 | 'season': 'Season 3', | |
30 | 'season_number': 3, | |
31 | 'episode': 'Episode 2', | |
32 | 'episode_number': 2, | |
33 | }, | |
34 | 'skip': 'This video is only available for registered users' | |
35 | }, { | |
36 | 'url': 'https://www.goplay.be/video/a-family-for-thr-holidays-s1-aflevering-1#autoplay', | |
37 | 'info_dict': { | |
38 | 'id': '74e3ed07-748c-49e4-85a0-393a93337dbf', | |
39 | 'ext': 'mp4', | |
40 | 'title': 'A Family for the Holidays', | |
41 | }, | |
42 | 'skip': 'This video is only available for registered users' | |
43 | }] | |
44 | ||
45 | _id_token = None | |
46 | ||
47 | def _perform_login(self, username, password): | |
48 | self.report_login() | |
49 | aws = AwsIdp(ie=self, pool_id='eu-west-1_dViSsKM5Y', client_id='6s1h851s8uplco5h6mqh1jac8m') | |
50 | self._id_token, _ = aws.authenticate(username=username, password=password) | |
51 | ||
52 | def _real_initialize(self): | |
53 | if not self._id_token: | |
54 | raise self.raise_login_required(method='password') | |
55 | ||
56 | def _real_extract(self, url): | |
57 | url, display_id = self._match_valid_url(url).group(0, 'display_id') | |
58 | webpage = self._download_webpage(url, display_id) | |
59 | video_data_json = self._html_search_regex(r'<div\s+data-hero="([^"]+)"', webpage, 'video_data') | |
60 | video_data = self._parse_json(unescapeHTML(video_data_json), display_id).get('data') | |
61 | ||
62 | movie = video_data.get('movie') | |
63 | if movie: | |
64 | video_id = movie['videoUuid'] | |
65 | info_dict = { | |
66 | 'title': movie.get('title') | |
67 | } | |
68 | else: | |
69 | episode = traverse_obj(video_data, ('playlists', ..., 'episodes', lambda _, v: v['pageInfo']['url'] == url), get_all=False) | |
70 | video_id = episode['videoUuid'] | |
71 | info_dict = { | |
72 | 'title': episode.get('episodeTitle'), | |
73 | 'series': traverse_obj(episode, ('program', 'title')), | |
74 | 'season_number': episode.get('seasonNumber'), | |
75 | 'episode_number': episode.get('episodeNumber'), | |
76 | } | |
77 | ||
78 | api = self._download_json( | |
79 | f'https://api.viervijfzes.be/content/{video_id}', | |
80 | video_id, headers={'Authorization': self._id_token}) | |
81 | ||
82 | formats, subs = self._extract_m3u8_formats_and_subtitles( | |
83 | api['video']['S'], video_id, ext='mp4', m3u8_id='HLS') | |
84 | self._sort_formats(formats) | |
85 | ||
86 | info_dict.update({ | |
87 | 'id': video_id, | |
88 | 'formats': formats, | |
89 | }) | |
90 | ||
91 | return info_dict | |
92 | ||
93 | ||
94 | # Taken from https://github.com/add-ons/plugin.video.viervijfzes/blob/master/resources/lib/viervijfzes/auth_awsidp.py | |
95 | # Released into Public domain by https://github.com/michaelarnauts | |
96 | ||
97 | class InvalidLoginException(ExtractorError): | |
98 | """ The login credentials are invalid """ | |
99 | ||
100 | ||
101 | class AuthenticationException(ExtractorError): | |
102 | """ Something went wrong while logging in """ | |
103 | ||
104 | ||
105 | class AwsIdp: | |
106 | """ AWS Identity Provider """ | |
107 | ||
108 | def __init__(self, ie, pool_id, client_id): | |
109 | """ | |
110 | :param InfoExtrator ie: The extractor that instantiated this class. | |
111 | :param str pool_id: The AWS user pool to connect to (format: <region>_<poolid>). | |
112 | E.g.: eu-west-1_aLkOfYN3T | |
113 | :param str client_id: The client application ID (the ID of the application connecting) | |
114 | """ | |
115 | ||
116 | self.ie = ie | |
117 | ||
118 | self.pool_id = pool_id | |
119 | if "_" not in self.pool_id: | |
120 | raise ValueError("Invalid pool_id format. Should be <region>_<poolid>.") | |
121 | ||
122 | self.client_id = client_id | |
123 | self.region = self.pool_id.split("_")[0] | |
124 | self.url = "https://cognito-idp.%s.amazonaws.com/" % (self.region,) | |
125 | ||
126 | # Initialize the values | |
127 | # https://github.com/aws/amazon-cognito-identity-js/blob/master/src/AuthenticationHelper.js#L22 | |
128 | self.n_hex = 'FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1' + \ | |
129 | '29024E088A67CC74020BBEA63B139B22514A08798E3404DD' + \ | |
130 | 'EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245' + \ | |
131 | 'E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED' + \ | |
132 | 'EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D' + \ | |
133 | 'C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F' + \ | |
134 | '83655D23DCA3AD961C62F356208552BB9ED529077096966D' + \ | |
135 | '670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B' + \ | |
136 | 'E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9' + \ | |
137 | 'DE2BCBF6955817183995497CEA956AE515D2261898FA0510' + \ | |
138 | '15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64' + \ | |
139 | 'ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7' + \ | |
140 | 'ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B' + \ | |
141 | 'F12FFA06D98A0864D87602733EC86A64521F2B18177B200C' + \ | |
142 | 'BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31' + \ | |
143 | '43DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF' | |
144 | ||
145 | # https://github.com/aws/amazon-cognito-identity-js/blob/master/src/AuthenticationHelper.js#L49 | |
146 | self.g_hex = '2' | |
147 | self.info_bits = bytearray('Caldera Derived Key', 'utf-8') | |
148 | ||
149 | self.big_n = self.__hex_to_long(self.n_hex) | |
150 | self.g = self.__hex_to_long(self.g_hex) | |
151 | self.k = self.__hex_to_long(self.__hex_hash('00' + self.n_hex + '0' + self.g_hex)) | |
152 | self.small_a_value = self.__generate_random_small_a() | |
153 | self.large_a_value = self.__calculate_a() | |
154 | ||
155 | def authenticate(self, username, password): | |
156 | """ Authenticate with a username and password. """ | |
157 | # Step 1: First initiate an authentication request | |
158 | auth_data_dict = self.__get_authentication_request(username) | |
159 | auth_data = json.dumps(auth_data_dict).encode("utf-8") | |
160 | auth_headers = { | |
161 | "X-Amz-Target": "AWSCognitoIdentityProviderService.InitiateAuth", | |
162 | "Accept-Encoding": "identity", | |
163 | "Content-Type": "application/x-amz-json-1.1" | |
164 | } | |
165 | auth_response_json = self.ie._download_json( | |
166 | self.url, None, data=auth_data, headers=auth_headers, | |
167 | note='Authenticating username', errnote='Invalid username') | |
168 | challenge_parameters = auth_response_json.get("ChallengeParameters") | |
169 | ||
170 | if auth_response_json.get("ChallengeName") != "PASSWORD_VERIFIER": | |
171 | raise AuthenticationException(auth_response_json["message"]) | |
172 | ||
173 | # Step 2: Respond to the Challenge with a valid ChallengeResponse | |
174 | challenge_request = self.__get_challenge_response_request(challenge_parameters, password) | |
175 | challenge_data = json.dumps(challenge_request).encode("utf-8") | |
176 | challenge_headers = { | |
177 | "X-Amz-Target": "AWSCognitoIdentityProviderService.RespondToAuthChallenge", | |
178 | "Content-Type": "application/x-amz-json-1.1" | |
179 | } | |
180 | auth_response_json = self.ie._download_json( | |
181 | self.url, None, data=challenge_data, headers=challenge_headers, | |
182 | note='Authenticating password', errnote='Invalid password') | |
183 | ||
184 | if 'message' in auth_response_json: | |
185 | raise InvalidLoginException(auth_response_json['message']) | |
186 | return ( | |
187 | auth_response_json['AuthenticationResult']['IdToken'], | |
188 | auth_response_json['AuthenticationResult']['RefreshToken'] | |
189 | ) | |
190 | ||
191 | def __get_authentication_request(self, username): | |
192 | """ | |
193 | ||
194 | :param str username: The username to use | |
195 | ||
196 | :return: A full Authorization request. | |
197 | :rtype: dict | |
198 | """ | |
199 | auth_request = { | |
200 | "AuthParameters": { | |
201 | "USERNAME": username, | |
202 | "SRP_A": self.__long_to_hex(self.large_a_value) | |
203 | }, | |
204 | "AuthFlow": "USER_SRP_AUTH", | |
205 | "ClientId": self.client_id | |
206 | } | |
207 | return auth_request | |
208 | ||
209 | def __get_challenge_response_request(self, challenge_parameters, password): | |
210 | """ Create a Challenge Response Request object. | |
211 | ||
212 | :param dict[str,str|imt] challenge_parameters: The parameters for the challenge. | |
213 | :param str password: The password. | |
214 | ||
215 | :return: A valid and full request data object to use as a response for a challenge. | |
216 | :rtype: dict | |
217 | """ | |
218 | user_id = challenge_parameters["USERNAME"] | |
219 | user_id_for_srp = challenge_parameters["USER_ID_FOR_SRP"] | |
220 | srp_b = challenge_parameters["SRP_B"] | |
221 | salt = challenge_parameters["SALT"] | |
222 | secret_block = challenge_parameters["SECRET_BLOCK"] | |
223 | ||
224 | timestamp = self.__get_current_timestamp() | |
225 | ||
226 | # Get a HKDF key for the password, SrpB and the Salt | |
227 | hkdf = self.__get_hkdf_key_for_password( | |
228 | user_id_for_srp, | |
229 | password, | |
230 | self.__hex_to_long(srp_b), | |
231 | salt | |
232 | ) | |
233 | secret_block_bytes = base64.standard_b64decode(secret_block) | |
234 | ||
235 | # the message is a combo of the pool_id, provided SRP userId, the Secret and Timestamp | |
236 | msg = \ | |
237 | bytearray(self.pool_id.split('_')[1], 'utf-8') + \ | |
238 | bytearray(user_id_for_srp, 'utf-8') + \ | |
239 | bytearray(secret_block_bytes) + \ | |
240 | bytearray(timestamp, 'utf-8') | |
241 | hmac_obj = hmac.new(hkdf, msg, digestmod=hashlib.sha256) | |
242 | signature_string = base64.standard_b64encode(hmac_obj.digest()).decode('utf-8') | |
243 | challenge_request = { | |
244 | "ChallengeResponses": { | |
245 | "USERNAME": user_id, | |
246 | "TIMESTAMP": timestamp, | |
247 | "PASSWORD_CLAIM_SECRET_BLOCK": secret_block, | |
248 | "PASSWORD_CLAIM_SIGNATURE": signature_string | |
249 | }, | |
250 | "ChallengeName": "PASSWORD_VERIFIER", | |
251 | "ClientId": self.client_id | |
252 | } | |
253 | return challenge_request | |
254 | ||
255 | def __get_hkdf_key_for_password(self, username, password, server_b_value, salt): | |
256 | """ Calculates the final hkdf based on computed S value, and computed U value and the key. | |
257 | ||
258 | :param str username: Username. | |
259 | :param str password: Password. | |
260 | :param int server_b_value: Server B value. | |
261 | :param int salt: Generated salt. | |
262 | ||
263 | :return Computed HKDF value. | |
264 | :rtype: object | |
265 | """ | |
266 | ||
267 | u_value = self.__calculate_u(self.large_a_value, server_b_value) | |
268 | if u_value == 0: | |
269 | raise ValueError('U cannot be zero.') | |
270 | username_password = '%s%s:%s' % (self.pool_id.split('_')[1], username, password) | |
271 | username_password_hash = self.__hash_sha256(username_password.encode('utf-8')) | |
272 | ||
273 | x_value = self.__hex_to_long(self.__hex_hash(self.__pad_hex(salt) + username_password_hash)) | |
274 | g_mod_pow_xn = pow(self.g, x_value, self.big_n) | |
275 | int_value2 = server_b_value - self.k * g_mod_pow_xn | |
276 | s_value = pow(int_value2, self.small_a_value + u_value * x_value, self.big_n) | |
277 | hkdf = self.__compute_hkdf( | |
278 | bytearray.fromhex(self.__pad_hex(s_value)), | |
279 | bytearray.fromhex(self.__pad_hex(self.__long_to_hex(u_value))) | |
280 | ) | |
281 | return hkdf | |
282 | ||
283 | def __compute_hkdf(self, ikm, salt): | |
284 | """ Standard hkdf algorithm | |
285 | ||
286 | :param {Buffer} ikm Input key material. | |
287 | :param {Buffer} salt Salt value. | |
288 | :return {Buffer} Strong key material. | |
289 | """ | |
290 | ||
291 | prk = hmac.new(salt, ikm, hashlib.sha256).digest() | |
292 | info_bits_update = self.info_bits + bytearray(chr(1), 'utf-8') | |
293 | hmac_hash = hmac.new(prk, info_bits_update, hashlib.sha256).digest() | |
294 | return hmac_hash[:16] | |
295 | ||
296 | def __calculate_u(self, big_a, big_b): | |
297 | """ Calculate the client's value U which is the hash of A and B | |
298 | ||
299 | :param int big_a: Large A value. | |
300 | :param int big_b: Server B value. | |
301 | ||
302 | :return Computed U value. | |
303 | :rtype: int | |
304 | """ | |
305 | ||
306 | u_hex_hash = self.__hex_hash(self.__pad_hex(big_a) + self.__pad_hex(big_b)) | |
307 | return self.__hex_to_long(u_hex_hash) | |
308 | ||
309 | def __generate_random_small_a(self): | |
310 | """ Helper function to generate a random big integer | |
311 | ||
312 | :return a random value. | |
313 | :rtype: int | |
314 | """ | |
315 | random_long_int = self.__get_random(128) | |
316 | return random_long_int % self.big_n | |
317 | ||
318 | def __calculate_a(self): | |
319 | """ Calculate the client's public value A = g^a%N with the generated random number a | |
320 | ||
321 | :return Computed large A. | |
322 | :rtype: int | |
323 | """ | |
324 | ||
325 | big_a = pow(self.g, self.small_a_value, self.big_n) | |
326 | # safety check | |
327 | if (big_a % self.big_n) == 0: | |
328 | raise ValueError('Safety check for A failed') | |
329 | return big_a | |
330 | ||
331 | @staticmethod | |
332 | def __long_to_hex(long_num): | |
333 | return '%x' % long_num | |
334 | ||
335 | @staticmethod | |
336 | def __hex_to_long(hex_string): | |
337 | return int(hex_string, 16) | |
338 | ||
339 | @staticmethod | |
340 | def __hex_hash(hex_string): | |
341 | return AwsIdp.__hash_sha256(bytearray.fromhex(hex_string)) | |
342 | ||
343 | @staticmethod | |
344 | def __hash_sha256(buf): | |
345 | """AuthenticationHelper.hash""" | |
346 | digest = hashlib.sha256(buf).hexdigest() | |
347 | return (64 - len(digest)) * '0' + digest | |
348 | ||
349 | @staticmethod | |
350 | def __pad_hex(long_int): | |
351 | """ Converts a Long integer (or hex string) to hex format padded with zeroes for hashing | |
352 | ||
353 | :param int|str long_int: Number or string to pad. | |
354 | ||
355 | :return Padded hex string. | |
356 | :rtype: str | |
357 | """ | |
358 | ||
359 | if not isinstance(long_int, str): | |
360 | hash_str = AwsIdp.__long_to_hex(long_int) | |
361 | else: | |
362 | hash_str = long_int | |
363 | if len(hash_str) % 2 == 1: | |
364 | hash_str = '0%s' % hash_str | |
365 | elif hash_str[0] in '89ABCDEFabcdef': | |
366 | hash_str = '00%s' % hash_str | |
367 | return hash_str | |
368 | ||
369 | @staticmethod | |
370 | def __get_random(nbytes): | |
371 | random_hex = binascii.hexlify(os.urandom(nbytes)) | |
372 | return AwsIdp.__hex_to_long(random_hex) | |
373 | ||
374 | @staticmethod | |
375 | def __get_current_timestamp(): | |
376 | """ Creates a timestamp with the correct English format. | |
377 | ||
378 | :return: timestamp in format 'Sun Jan 27 19:00:04 UTC 2019' | |
379 | :rtype: str | |
380 | """ | |
381 | ||
382 | # We need US only data, so we cannot just do a strftime: | |
383 | # Sun Jan 27 19:00:04 UTC 2019 | |
384 | months = [None, 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] | |
385 | days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] | |
386 | ||
387 | time_now = datetime.datetime.utcnow() | |
388 | format_string = "{} {} {} %H:%M:%S UTC %Y".format(days[time_now.weekday()], months[time_now.month], time_now.day) | |
389 | time_string = datetime.datetime.utcnow().strftime(format_string) | |
390 | return time_string | |
391 | ||
392 | def __str__(self): | |
393 | return "AWS IDP Client for:\nRegion: %s\nPoolId: %s\nAppId: %s" % ( | |
394 | self.region, self.pool_id.split("_")[1], self.client_id | |
395 | ) |