mirror of
https://github.com/danieldemus/openhab-addons
synced 2026-07-29 12:34:21 +02:00
[sunsynk] Adapt to new authentication requirements (#19656)
* Sunsynk Client Authorisation breaking changes: SunSynk initial connection now require a nonce, sign and public key to receive the bearer token. Signed-off-by: LeeC77 <lee.charlton00@gmail.com>
This commit is contained in:
+146
-22
@@ -16,11 +16,22 @@ import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Objects;
|
||||
import java.util.Properties;
|
||||
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import javax.ws.rs.core.HttpHeaders;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
|
||||
@@ -32,8 +43,10 @@ import org.openhab.binding.sunsynk.internal.api.dto.Client;
|
||||
import org.openhab.binding.sunsynk.internal.api.dto.Details;
|
||||
import org.openhab.binding.sunsynk.internal.api.dto.Inverter;
|
||||
import org.openhab.binding.sunsynk.internal.api.dto.SunSynkLogin;
|
||||
import org.openhab.binding.sunsynk.internal.api.dto.SunSynkPublicKey;
|
||||
import org.openhab.binding.sunsynk.internal.api.dto.TokenRefresh;
|
||||
import org.openhab.binding.sunsynk.internal.api.exception.SunSynkAuthenticateException;
|
||||
import org.openhab.binding.sunsynk.internal.api.exception.SunSynkClientAuthenticateException;
|
||||
import org.openhab.binding.sunsynk.internal.api.exception.SunSynkInverterDiscoveryException;
|
||||
import org.openhab.binding.sunsynk.internal.api.exception.SunSynkTokenException;
|
||||
import org.openhab.core.io.net.http.HttpUtil;
|
||||
@@ -56,23 +69,63 @@ public class AccountController {
|
||||
private final Logger logger = LoggerFactory.getLogger(AccountController.class);
|
||||
private static final String BEARER_TYPE = "Bearer ";
|
||||
private static final long EXPIRYSECONDS = 100L; // 100 seconds before expiry
|
||||
private static final String SECRET_KEY = "POWER_VIEW"; // Is the SunSynk Connect App secret key
|
||||
private static final String SOURCE = "sunsynk"; // Is the SunSynk Connect App source identifier
|
||||
private Client sunAccount = new Client();
|
||||
private SunSynkPublicKey publicKey = new SunSynkPublicKey();
|
||||
|
||||
public AccountController() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticates the Account Thing as a Sunsynk Client,
|
||||
* then encrypts the user password to call authentication of the user credentials
|
||||
*
|
||||
* @param username The username you use with Sunsynk Connect App
|
||||
* @param userPassword The password you use with Sunsynk Connect App
|
||||
* @throws SunSynkAuthenticateException
|
||||
* @throws SunSynkTokenException
|
||||
*/
|
||||
public void clientAuthenticate(String username, String userPassword)
|
||||
throws SunSynkClientAuthenticateException, SunSynkAuthenticateException {
|
||||
long nonce = Instant.now().toEpochMilli();
|
||||
try {
|
||||
String authEndpoint = "nonce=" + String.valueOf(nonce) + "&source=" + SOURCE + "&sign=" + getSign(nonce);
|
||||
httpGetPublicKey(authEndpoint);
|
||||
String encryptedPassword = getEncryptPassword(userPassword, this.publicKey.getPublicKey());
|
||||
userAuthenticate(username, encryptedPassword);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new SunSynkClientAuthenticateException("Error attempting to authenticate client:" + e.getMessage());
|
||||
} catch (NoSuchPaddingException e) {
|
||||
throw new SunSynkClientAuthenticateException(
|
||||
"Required encryption algorithm or padding not available: " + e.getMessage());
|
||||
} catch (InvalidKeySpecException e) {
|
||||
throw new SunSynkClientAuthenticateException("The provided public key is invalid: " + e.getMessage());
|
||||
} catch (IllegalBlockSizeException | BadPaddingException e) {
|
||||
throw new SunSynkClientAuthenticateException("Error processing data block size/padding: " + e.getMessage());
|
||||
} catch (SunSynkAuthenticateException e) {
|
||||
String message = Objects.requireNonNullElse(e.getMessage(), "An unknown authentication error occurred");
|
||||
throw new SunSynkAuthenticateException(message);
|
||||
} catch (SunSynkTokenException e) {
|
||||
String message = Objects.requireNonNullElse(e.getMessage(), "An unknown token error occurred");
|
||||
throw new SunSynkAuthenticateException(message);
|
||||
} catch (Exception e) { // Catch the generic one last
|
||||
throw new SunSynkClientAuthenticateException("An unexpected error occurred: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticates a Sunsynk Connect API account using a username and password.
|
||||
*
|
||||
* @param username The username you use with Sunsynk Connect App
|
||||
* @param password The password you use with Sunsynk Connect App
|
||||
* @param encryptedPassword The password you use with Sunsynk Connect App, salted and encrypted with the public key
|
||||
* @throws SunSynkAuthenticateException
|
||||
* @throws SunSynkTokenException
|
||||
*/
|
||||
public void authenticate(String username, String password)
|
||||
public void userAuthenticate(String username, String saltedPassword)
|
||||
throws SunSynkAuthenticateException, SunSynkTokenException {
|
||||
String payload = makeLoginBody(username, password);
|
||||
sendHttp(payload);
|
||||
String payload = makeLoginBody(username, saltedPassword);
|
||||
httpTokenPost(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,29 +147,53 @@ public class AccountController {
|
||||
}
|
||||
logger.debug("Account configuration token expired : {}", this.sunAccount.getData().toString());
|
||||
String payload = makeRefreshBody(username, this.sunAccount.getRefreshTokenString());
|
||||
sendHttp(payload);
|
||||
httpTokenPost(payload);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused") // We need client to be nullable. Then we check for null. Without this compiler warns of
|
||||
// unsed block under null check
|
||||
private void sendHttp(String payload) throws SunSynkAuthenticateException, SunSynkTokenException {
|
||||
private void httpGetPublicKey(String endpoint) throws SunSynkClientAuthenticateException, JsonSyntaxException {
|
||||
Gson gson = new Gson();
|
||||
String response = "";
|
||||
String httpsURL = makeLoginURL("oauth/token");
|
||||
String httpsURL = makeLoginURL("anonymous/publicKey?" + endpoint);
|
||||
Properties headers = new Properties();
|
||||
headers.setProperty(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON);
|
||||
headers.setProperty(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
|
||||
try {
|
||||
response = HttpUtil.executeUrl(HttpMethod.GET.asString(), httpsURL, headers, null,
|
||||
MediaType.APPLICATION_JSON, TIMEOUT_IN_MS);
|
||||
if (gson.fromJson(response, SunSynkPublicKey.class) instanceof SunSynkPublicKey key) {
|
||||
this.publicKey = key;
|
||||
return;
|
||||
}
|
||||
throw new SunSynkClientAuthenticateException("Failed get private key");
|
||||
} catch (IOException | JsonSyntaxException e) {
|
||||
if (logger.isDebugEnabled()) {
|
||||
String message = Objects.requireNonNullElse(e.getMessage(), "unknown error message");
|
||||
Throwable cause = e.getCause();
|
||||
String causeMessage = cause != null ? Objects.requireNonNullElse(cause.getMessage(), "unknown cause")
|
||||
: "unknown cause";
|
||||
logger.debug("Error authorising Account Thing: Msg = {}. Cause = {}.", message, causeMessage);
|
||||
}
|
||||
throw new SunSynkClientAuthenticateException("Account Thing authorisation failed");
|
||||
}
|
||||
}
|
||||
|
||||
private void httpTokenPost(String payload) throws SunSynkAuthenticateException, SunSynkTokenException {
|
||||
Gson gson = new Gson();
|
||||
String response = "";
|
||||
String httpsURL = makeLoginURL("oauth/token/new");
|
||||
Properties headers = new Properties();
|
||||
headers.setProperty(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON);
|
||||
headers.setProperty(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
|
||||
InputStream stream = new ByteArrayInputStream(payload.getBytes(StandardCharsets.UTF_8));
|
||||
try {
|
||||
response = HttpUtil.executeUrl(HttpMethod.POST.asString(), httpsURL, headers, stream,
|
||||
MediaType.APPLICATION_JSON, TIMEOUT_IN_MS);
|
||||
|
||||
@Nullable
|
||||
Client client = gson.fromJson(response, Client.class);
|
||||
if (client == null) {
|
||||
if (gson.fromJson(response, Client.class) instanceof Client client) {
|
||||
this.sunAccount = client;
|
||||
} else {
|
||||
throw new SunSynkAuthenticateException(
|
||||
"Sun Synk account could not be authenticated: Try re-enabling account");
|
||||
"Synk account could not be authenticated: Try re-enabling account");
|
||||
}
|
||||
this.sunAccount = client;
|
||||
} catch (IOException | JsonSyntaxException e) {
|
||||
throw new SunSynkAuthenticateException("Sun Synk account could not be authenticated", e);
|
||||
}
|
||||
@@ -133,15 +210,43 @@ public class AccountController {
|
||||
getToken();
|
||||
}
|
||||
|
||||
private void getToken() throws SunSynkAuthenticateException {
|
||||
APIdata data = this.sunAccount.getData();
|
||||
APIdata.staticAccessToken = data.getAccessToken();
|
||||
/**
|
||||
* Performs RSA encryption on raw data using a provided public key string.
|
||||
* It handles Base64 decoding, key loading, encryption with PKCS1 padding,
|
||||
* and Base64 encoding of the final output.
|
||||
*
|
||||
* @param userPassword The raw, unpadded byte array of credentials (e.g., "password").
|
||||
* @param publicKeyString The Base64 encoded X.509 public key string (the MIIC... string).
|
||||
* @return The final Base64 encoded encrypted password string.
|
||||
* @throws Exception if any crypto operations fail.
|
||||
*/
|
||||
private String getEncryptPassword(String userPassword, String publicKeyString) throws Exception {
|
||||
// Prepare the credentials as bytes
|
||||
byte[] rawCredentials = (userPassword).getBytes(StandardCharsets.UTF_8);
|
||||
// Load the Public Key using DER format
|
||||
// The public_key_string (String) is Base64 decoded into raw bytes (bytes)
|
||||
byte[] publicKeyBytes = Base64.getDecoder().decode(publicKeyString);
|
||||
// Load the key using the X.509/DER loader
|
||||
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
|
||||
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicKeyBytes);
|
||||
PublicKey publicKey = keyFactory.generatePublic(keySpec);
|
||||
// Encrypt the raw data using library's built-in padding
|
||||
// Use the specific algorithm/padding scheme: RSA/ECB/PKCS1Padding
|
||||
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
|
||||
// The library handles generating 512 bytes, padding, and random bytes automatically
|
||||
byte[] encryptedBytes = cipher.doFinal(rawCredentials);
|
||||
// Format the Output
|
||||
// The website likely sends the encrypted bytes as a Base64 encoded string
|
||||
String finalEncryptedPassword = Base64.getEncoder().encodeToString(encryptedBytes);
|
||||
logger.trace("The final encrypted password string is: {} ", finalEncryptedPassword);
|
||||
return finalEncryptedPassword;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers a list of all inverter tied to a Sunsynk Connect Account
|
||||
*
|
||||
* @return List of connected inveters
|
||||
* @return List of connected inverters
|
||||
* @throws SunSynkInverterDiscoveryException
|
||||
*/
|
||||
@SuppressWarnings("unused")
|
||||
@@ -160,11 +265,11 @@ public class AccountController {
|
||||
MediaType.APPLICATION_JSON, TIMEOUT_IN_MS);
|
||||
logger.trace("Account Details Response: {}", response);
|
||||
@Nullable
|
||||
Details maybeDeats = gson.fromJson(response, Details.class);
|
||||
if (maybeDeats == null) {
|
||||
Details maybeDetails = gson.fromJson(response, Details.class);
|
||||
if (maybeDetails == null) {
|
||||
throw new SunSynkInverterDiscoveryException("Failed to discover Inverters");
|
||||
}
|
||||
output = maybeDeats;
|
||||
output = maybeDetails;
|
||||
} catch (IOException | JsonSyntaxException e) {
|
||||
if (logger.isDebugEnabled()) {
|
||||
String message = Objects.requireNonNullElse(e.getMessage(), "unknown error message");
|
||||
@@ -196,6 +301,25 @@ public class AccountController {
|
||||
return gson.toJson(refresh);
|
||||
}
|
||||
|
||||
private void getToken() throws SunSynkAuthenticateException {
|
||||
APIdata data = this.sunAccount.getData();
|
||||
APIdata.staticAccessToken = data.getAccessToken();
|
||||
}
|
||||
|
||||
private String getSign(Long nonce) throws NoSuchAlgorithmException {
|
||||
String inputString = "nonce=" + String.valueOf(nonce) + "&source=" + SOURCE + SECRET_KEY;
|
||||
|
||||
MessageDigest messageDigest = MessageDigest.getInstance("MD5");
|
||||
byte[] hashBytes = messageDigest.digest(inputString.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
logger.trace("nonce : {} encrypted nonce : {}", nonce.toString(), sb.toString());
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
try {
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ public class Client {
|
||||
public Client() {
|
||||
}
|
||||
|
||||
public static String getAccessTokenString() {
|
||||
public String getAccessTokenString() {
|
||||
return APIdata.staticAccessToken;
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -35,6 +35,8 @@ public class SunSynkLogin {
|
||||
private String grantType = "password";
|
||||
@SerializedName("client_id")
|
||||
private String clientId = "csp-web";
|
||||
@SerializedName("source")
|
||||
private String source = "sunsynk";
|
||||
|
||||
public SunSynkLogin(String UserName, String PassWord) {
|
||||
this.userName = UserName;
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.sunsynk.internal.api.dto;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link SunSynkPublicKey} is the internal class for the SunSynk public key
|
||||
* for a SunSynk Connect Account.
|
||||
* Login via Username and Password
|
||||
*
|
||||
* @author Lee Charlton - Initial contribution
|
||||
*/
|
||||
|
||||
@NonNullByDefault
|
||||
public class SunSynkPublicKey {
|
||||
private int code;
|
||||
private String msg = "";
|
||||
private boolean success;
|
||||
private String data = "";
|
||||
|
||||
public String getPublicKey() {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "code = " + this.code + " message = " + this.msg + " success = " + this.success;
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.sunsynk.internal.api.exception;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link SunSynkClientAuthenticateException}{@link Exception}
|
||||
*
|
||||
* @author Lee Charlton - Initial contribution
|
||||
*/
|
||||
|
||||
@NonNullByDefault
|
||||
public class SunSynkClientAuthenticateException extends Exception {
|
||||
|
||||
private static final long serialVersionUID = 6L;
|
||||
|
||||
public SunSynkClientAuthenticateException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public SunSynkClientAuthenticateException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public SunSynkClientAuthenticateException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
||||
+12
-9
@@ -14,7 +14,6 @@ package org.openhab.binding.sunsynk.internal.handler;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@@ -23,6 +22,7 @@ import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.sunsynk.internal.api.AccountController;
|
||||
import org.openhab.binding.sunsynk.internal.api.dto.Inverter;
|
||||
import org.openhab.binding.sunsynk.internal.api.exception.SunSynkAuthenticateException;
|
||||
import org.openhab.binding.sunsynk.internal.api.exception.SunSynkClientAuthenticateException;
|
||||
import org.openhab.binding.sunsynk.internal.api.exception.SunSynkInverterDiscoveryException;
|
||||
import org.openhab.binding.sunsynk.internal.api.exception.SunSynkTokenException;
|
||||
import org.openhab.binding.sunsynk.internal.config.SunSynkAccountConfig;
|
||||
@@ -108,17 +108,20 @@ public class SunSynkAccountHandler extends BaseBridgeHandler {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.sunAccount.authenticate(accountConfig.getEmail(), accountConfig.getPassword());
|
||||
} catch (SunSynkAuthenticateException | SunSynkTokenException e) {
|
||||
this.sunAccount.clientAuthenticate(accountConfig.getEmail(), accountConfig.getPassword());
|
||||
} catch (SunSynkClientAuthenticateException e) {
|
||||
if (logger.isDebugEnabled()) {
|
||||
String message = Objects.requireNonNullElse(e.getMessage(), "unkown error message");
|
||||
Throwable cause = e.getCause();
|
||||
String causeMessage = cause != null ? Objects.requireNonNullElse(cause.getMessage(), "unkown cause")
|
||||
: "unkown cause";
|
||||
logger.debug("Error attempting to authenticate account Msg = {} Cause = {}", message, causeMessage);
|
||||
logger.debug("Error attempting to authenticate client: {}.", e.getMessage());
|
||||
}
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"Error attempting to authenticate account");
|
||||
"Error attempting to authenticate binding with SunSynk");
|
||||
return;
|
||||
} catch (SunSynkAuthenticateException e) {
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("Error attempting to authenticate user: {}.", e.getMessage());
|
||||
}
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"Error attempting to authenticate user credentials");
|
||||
return;
|
||||
}
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
|
||||
Reference in New Issue
Block a user