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