]> git.basschouten.com Git - openhab-addons.git/blob
180c3e49a40458f9113c448c9ac5b38462bfefeb
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.windcentrale.internal.api;
14
15 import static java.nio.charset.StandardCharsets.UTF_8;
16 import static org.eclipse.jetty.http.HttpMethod.POST;
17 import static org.openhab.binding.windcentrale.internal.dto.CognitoGson.GSON;
18
19 import java.math.BigInteger;
20 import java.security.InvalidKeyException;
21 import java.security.MessageDigest;
22 import java.security.NoSuchAlgorithmException;
23 import java.security.SecureRandom;
24 import java.time.Duration;
25 import java.time.Instant;
26 import java.time.ZoneId;
27 import java.time.format.DateTimeFormatter;
28 import java.util.Base64;
29 import java.util.Locale;
30 import java.util.Objects;
31 import java.util.concurrent.ExecutionException;
32 import java.util.concurrent.TimeUnit;
33 import java.util.concurrent.TimeoutException;
34
35 import javax.crypto.Mac;
36 import javax.crypto.SecretKey;
37 import javax.crypto.spec.SecretKeySpec;
38
39 import org.eclipse.jdt.annotation.NonNullByDefault;
40 import org.eclipse.jdt.annotation.Nullable;
41 import org.eclipse.jetty.client.HttpClient;
42 import org.eclipse.jetty.client.api.ContentResponse;
43 import org.eclipse.jetty.client.util.StringContentProvider;
44 import org.openhab.binding.windcentrale.internal.dto.AuthenticationResultResponse;
45 import org.openhab.binding.windcentrale.internal.dto.ChallengeResponse;
46 import org.openhab.binding.windcentrale.internal.dto.CognitoError;
47 import org.openhab.binding.windcentrale.internal.dto.InitiateAuthRequest;
48 import org.openhab.binding.windcentrale.internal.dto.RespondToAuthChallengeRequest;
49 import org.openhab.binding.windcentrale.internal.exception.InvalidAccessTokenException;
50 import org.openhab.core.io.net.http.HttpClientFactory;
51 import org.slf4j.Logger;
52 import org.slf4j.LoggerFactory;
53
54 /**
55  * Helps with authenticating users to Amazon Cognito to get a JWT access token which can be used for retrieving
56  * information using the REST APIs.
57  *
58  * @see <a href="https://en.wikipedia.org/wiki/Secure_Remote_Password_protocol">
59  *      https://en.wikipedia.org/wiki/Secure_Remote_Password_protocol</a>
60  * @see <a href="https://stackoverflow.com/questions/67528443/cognito-srp-using-aws-java-sdk-v2-x">
61  *      https://stackoverflow.com/questions/67528443/cognito-srp-using-aws-java-sdk-v2-x</a>
62  * @see <a href=
63  *      "https://github.com/aws-samples/aws-cognito-java-desktop-app/blob/master/src/main/java/com/amazonaws/sample/cognitoui/AuthenticationHelper.java">
64  *      https://github.com/aws-samples/aws-cognito-java-desktop-app/blob/master/src/main/java/com/amazonaws/sample/cognitoui/AuthenticationHelper.java</a>
65  *
66  * @author Wouter Born - Initial contribution
67  */
68 @NonNullByDefault
69 public class AuthenticationHelper {
70
71     private final Logger logger = LoggerFactory.getLogger(AuthenticationHelper.class);
72
73     private static final String SRP_N_HEX = "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" //
74             + "29024E088A67CC74020BBEA63B139B22514A08798E3404DD" //
75             + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" //
76             + "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" //
77             + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" //
78             + "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" //
79             + "83655D23DCA3AD961C62F356208552BB9ED529077096966D" //
80             + "670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" //
81             + "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9" //
82             + "DE2BCBF6955817183995497CEA956AE515D2261898FA0510" //
83             + "15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64" //
84             + "ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7" //
85             + "ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B" //
86             + "F12FFA06D98A0864D87602733EC86A64521F2B18177B200C" //
87             + "BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31" //
88             + "43DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF";
89
90     private static final BigInteger SRP_A;
91     private static final BigInteger SRP_A2;
92     private static final BigInteger SRP_G = BigInteger.valueOf(2);
93     private static final BigInteger SRP_K;
94     private static final BigInteger SRP_N = new BigInteger(SRP_N_HEX, 16);
95
96     private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter
97             .ofPattern("EEE MMM d HH:mm:ss z yyyy", Locale.US).withZone(ZoneId.of("UTC"));
98     private static final int DERIVED_KEY_SIZE = 16;
99     private static final int EPHEMERAL_KEY_LENGTH = 1024;
100     private static final String DERIVED_KEY_INFO = "Caldera Derived Key";
101     private static final Duration REQUEST_TIMEOUT = Duration.ofMinutes(1);
102
103     private static final String COGNITO_URL_FORMAT = "https://cognito-idp.%s.amazonaws.com/";
104     private static final String INITIATE_AUTH_TARGET = "AWSCognitoIdentityProviderService.InitiateAuth";
105     private static final String RESPOND_TO_AUTH_TARGET = "AWSCognitoIdentityProviderService.RespondToAuthChallenge";
106
107     /**
108      * Internal class for doing the HKDF calculations.
109      */
110     private static final class Hkdf {
111         private static final int MAX_KEY_SIZE = 255;
112         private final String algorithm;
113         private @Nullable SecretKey prk;
114
115         /**
116          * @param algorithm The type of HMAC algorithm to be used
117          */
118         private Hkdf(String algorithm) {
119             if (!algorithm.startsWith("Hmac")) {
120                 throw new IllegalArgumentException(
121                         "Invalid algorithm " + algorithm + ". HKDF may only be used with HMAC algorithms.");
122             }
123             this.algorithm = algorithm;
124         }
125
126         /**
127          * @param ikm the input key material
128          * @param salt random bytes for salt
129          */
130         private void init(byte[] ikm, byte[] salt) {
131             try {
132                 Mac mac = Mac.getInstance(algorithm);
133                 byte[] realSalt = salt.length == 0 ? new byte[mac.getMacLength()] : salt.clone();
134                 mac.init(new SecretKeySpec(realSalt, algorithm));
135                 SecretKeySpec key = new SecretKeySpec(mac.doFinal(ikm), algorithm);
136                 unsafeInitWithoutKeyExtraction(key);
137             } catch (InvalidKeyException | NoSuchAlgorithmException e) {
138                 throw new IllegalStateException("Failed to initialize HKDF", e);
139             }
140         }
141
142         /**
143          * @param rawKey current secret key
144          */
145         private void unsafeInitWithoutKeyExtraction(SecretKey rawKey) {
146             if (!rawKey.getAlgorithm().equals(algorithm)) {
147                 throw new IllegalArgumentException(
148                         "Algorithm for the provided key must match the algorithm for this HKDF. Expected " + algorithm
149                                 + " but found " + rawKey.getAlgorithm());
150             } else {
151                 prk = rawKey;
152             }
153         }
154
155         private byte[] deriveKey(String info, int length) {
156             if (prk == null) {
157                 throw new IllegalStateException("HKDF has not been initialized");
158             }
159
160             if (length < 0) {
161                 throw new IllegalArgumentException("Length must be a non-negative value");
162             }
163
164             Mac mac = createMac();
165             if (length > MAX_KEY_SIZE * mac.getMacLength()) {
166                 throw new IllegalArgumentException(
167                         "Requested keys may not be longer than 255 times the underlying HMAC length");
168             }
169
170             byte[] result = new byte[length];
171             byte[] bytes = info.getBytes(UTF_8);
172             byte[] t = {};
173             int loc = 0;
174
175             for (byte i = 1; loc < length; ++i) {
176                 mac.update(t);
177                 mac.update(bytes);
178                 mac.update(i);
179                 t = mac.doFinal();
180
181                 for (int x = 0; x < t.length && loc < length; ++loc) {
182                     result[loc] = t[x];
183                     ++x;
184                 }
185             }
186
187             return result;
188         }
189
190         /**
191          * @return the generated message authentication code
192          */
193         private Mac createMac() {
194             try {
195                 Mac mac = Mac.getInstance(algorithm);
196                 mac.init(prk);
197                 return mac;
198             } catch (InvalidKeyException | NoSuchAlgorithmException e) {
199                 throw new IllegalStateException("Could not create MAC implementing algorithm: " + algorithm, e);
200             }
201         }
202     }
203
204     static {
205         // Initialize the SRP variables
206         try {
207             SecureRandom sr = SecureRandom.getInstance("SHA1PRNG");
208             MessageDigest md = MessageDigest.getInstance("SHA-256");
209             md.update(SRP_N.toByteArray());
210
211             byte[] digest = md.digest(SRP_G.toByteArray());
212             SRP_K = new BigInteger(1, digest);
213
214             BigInteger srpA;
215             BigInteger srpA2;
216             do {
217                 srpA2 = new BigInteger(EPHEMERAL_KEY_LENGTH, sr).mod(SRP_N);
218                 srpA = SRP_G.modPow(srpA2, SRP_N);
219             } while (srpA.mod(SRP_N).equals(BigInteger.ZERO));
220
221             SRP_A = srpA;
222             SRP_A2 = srpA2;
223         } catch (NoSuchAlgorithmException e) {
224             throw new IllegalStateException("SRP variables cannot be initialized due to missing algorithm", e);
225         }
226     }
227
228     private final HttpClient httpClient;
229     private final String userPoolId;
230     private final String clientId;
231     private final String region;
232
233     public AuthenticationHelper(HttpClientFactory httpClientFactory, String userPoolId, String clientId,
234             String region) {
235         this.httpClient = httpClientFactory.getCommonHttpClient();
236         this.userPoolId = userPoolId;
237         this.clientId = clientId;
238         this.region = region;
239     }
240
241     /**
242      * Method to orchestrate the SRP Authentication.
243      *
244      * @param username username for the SRP request
245      * @param password password for the SRP request
246      * @return JWT token if the request is successful
247      * @throws InvalidAccessTokenException when SRP authentication fails
248      */
249     public AuthenticationResultResponse performSrpAuthentication(String username, String password)
250             throws InvalidAccessTokenException {
251         InitiateAuthRequest initiateAuthRequest = InitiateAuthRequest.userSrpAuth(clientId, username,
252                 SRP_A.toString(16));
253         try {
254             ChallengeResponse challengeResponse = postInitiateAuthSrp(initiateAuthRequest);
255             if ("PASSWORD_VERIFIER".equals(challengeResponse.challengeName)) {
256                 RespondToAuthChallengeRequest challengeRequest = createRespondToAuthChallengeRequest(challengeResponse,
257                         password);
258                 return postRespondToAuthChallenge(challengeRequest);
259             } else {
260                 throw new InvalidAccessTokenException(
261                         "Unsupported authentication challenge: " + challengeResponse.challengeName);
262             }
263         } catch (IllegalStateException | InvalidKeyException | NoSuchAlgorithmException e) {
264             throw new InvalidAccessTokenException("SRP Authentication failed", e);
265         }
266     }
267
268     public AuthenticationResultResponse performTokenRefresh(String refreshToken) throws InvalidAccessTokenException {
269         InitiateAuthRequest initiateAuthRequest = InitiateAuthRequest.refreshTokenAuth(clientId, refreshToken);
270         try {
271             return postInitiateAuthRefresh(initiateAuthRequest);
272         } catch (IllegalStateException e) {
273             throw new InvalidAccessTokenException("Token refresh failed", e);
274         }
275     }
276
277     /**
278      * Creates a response request to the SRP authentication challenge from the user pool.
279      *
280      * @param challengeResponse authentication challenge returned from the Cognito user pool
281      * @param password password to be used to respond to the authentication challenge
282      * @return request created for the previous authentication challenge
283      */
284     private RespondToAuthChallengeRequest createRespondToAuthChallengeRequest(ChallengeResponse challengeResponse,
285             String password) throws InvalidKeyException, NoSuchAlgorithmException {
286         String salt = challengeResponse.getSalt();
287         String secretBlock = challengeResponse.getSecretBlock();
288         String userIdForSrp = challengeResponse.getUserIdForSrp();
289         String usernameInternal = challengeResponse.getUsername();
290
291         if (secretBlock.isEmpty() || userIdForSrp.isEmpty() || usernameInternal.isEmpty()) {
292             throw new IllegalArgumentException("Required authentication response challenge parameters are null");
293         }
294
295         BigInteger srpB = new BigInteger(challengeResponse.getSrpB(), 16);
296         if (srpB.mod(SRP_N).equals(BigInteger.ZERO)) {
297             throw new IllegalStateException("SRP error, B cannot be zero");
298         }
299
300         String timestamp = DATE_TIME_FORMATTER.format(Instant.now());
301
302         byte[] key = getPasswordAuthenticationKey(userIdForSrp, password, srpB, new BigInteger(salt, 16));
303
304         Mac mac = Mac.getInstance("HmacSHA256");
305         mac.init(new SecretKeySpec(key, "HmacSHA256"));
306         mac.update(userPoolId.split("_", 2)[1].getBytes(UTF_8));
307         mac.update(userIdForSrp.getBytes(UTF_8));
308         mac.update(Base64.getDecoder().decode(secretBlock));
309         byte[] hmac = mac.doFinal(timestamp.getBytes(UTF_8));
310
311         String signature = new String(Base64.getEncoder().encode(hmac), UTF_8);
312
313         return new RespondToAuthChallengeRequest(clientId, usernameInternal, secretBlock, signature, timestamp);
314     }
315
316     private byte[] getPasswordAuthenticationKey(String userId, String userPassword, BigInteger srpB, BigInteger salt) {
317         try {
318             // Authenticate the password
319             // srpU = H(SRP_A, srpB)
320             MessageDigest md = MessageDigest.getInstance("SHA-256");
321             md.update(SRP_A.toByteArray());
322
323             BigInteger srpU = new BigInteger(1, md.digest(srpB.toByteArray()));
324             if (srpU.equals(BigInteger.ZERO)) {
325                 throw new IllegalStateException("Hash of A and B cannot be zero");
326             }
327
328             // srpX = H(salt | H(poolName | userId | ":" | password))
329             md.reset();
330             md.update(userPoolId.split("_", 2)[1].getBytes(UTF_8));
331             md.update(userId.getBytes(UTF_8));
332             md.update(":".getBytes(UTF_8));
333
334             byte[] userIdHash = md.digest(userPassword.getBytes(UTF_8));
335
336             md.reset();
337             md.update(salt.toByteArray());
338
339             BigInteger srpX = new BigInteger(1, md.digest(userIdHash));
340             BigInteger srpS = (srpB.subtract(SRP_K.multiply(SRP_G.modPow(srpX, SRP_N)))
341                     .modPow(SRP_A2.add(srpU.multiply(srpX)), SRP_N)).mod(SRP_N);
342
343             Hkdf hkdf = new Hkdf("HmacSHA256");
344             hkdf.init(srpS.toByteArray(), srpU.toByteArray());
345             return hkdf.deriveKey(DERIVED_KEY_INFO, DERIVED_KEY_SIZE);
346         } catch (NoSuchAlgorithmException e) {
347             throw new IllegalStateException(e.getMessage(), e);
348         }
349     }
350
351     private ChallengeResponse postInitiateAuthSrp(InitiateAuthRequest request) throws InvalidAccessTokenException {
352         String responseContent = postJson(INITIATE_AUTH_TARGET, GSON.toJson(request));
353         return Objects.requireNonNull(GSON.fromJson(responseContent, ChallengeResponse.class));
354     }
355
356     private AuthenticationResultResponse postInitiateAuthRefresh(InitiateAuthRequest request)
357             throws InvalidAccessTokenException {
358         String responseContent = postJson(INITIATE_AUTH_TARGET, GSON.toJson(request));
359         return Objects.requireNonNull(GSON.fromJson(responseContent, AuthenticationResultResponse.class));
360     }
361
362     private AuthenticationResultResponse postRespondToAuthChallenge(RespondToAuthChallengeRequest request)
363             throws InvalidAccessTokenException {
364         String responseContent = postJson(RESPOND_TO_AUTH_TARGET, GSON.toJson(request));
365         return Objects.requireNonNull(GSON.fromJson(responseContent, AuthenticationResultResponse.class));
366     }
367
368     private String postJson(String target, String requestContent) throws InvalidAccessTokenException {
369         try {
370             String url = String.format(COGNITO_URL_FORMAT, region);
371             logger.debug("Posting JSON to: {}", url);
372             ContentResponse contentResponse = httpClient.newRequest(url) //
373                     .method(POST) //
374                     .header("x-amz-target", target) //
375                     .content(new StringContentProvider(requestContent), "application/x-amz-json-1.1") //
376                     .timeout(REQUEST_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS).send();
377
378             String response = contentResponse.getContentAsString();
379             if (contentResponse.getStatus() >= 400) {
380                 logger.debug("Cognito API error: {}", response);
381
382                 CognitoError error = GSON.fromJson(response, CognitoError.class);
383                 String message;
384                 if (error != null && !error.message.isBlank()) {
385                     message = String.format("Cognito API error: %s (%s)", error.message, error.type);
386                 } else {
387                     message = String.format("Cognito API error: %s (HTTP %s)", contentResponse.getReason(),
388                             contentResponse.getStatus());
389                 }
390                 throw new InvalidAccessTokenException(message);
391             } else {
392                 logger.trace("Response: {}", response);
393             }
394             return response;
395         } catch (InterruptedException | TimeoutException | ExecutionException e) {
396             throw new InvalidAccessTokenException("Cognito API request failed: " + e.getMessage(), e);
397         }
398     }
399 }