Skip to content

Commit f6e750a

Browse files
authored
[shelly] Fix Plus/Pro Auth support (#15284)
* Reimplement Auth Digist to return response as http header field instead of being part of post data; avoid sending Basic Auth for Gen2 and /shelly Signed-off-by: Markus Michels <markus7017@gmail.com>
1 parent ae36108 commit f6e750a

10 files changed

Lines changed: 150 additions & 78 deletions

File tree

bundles/org.openhab.binding.shelly/README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -997,7 +997,6 @@ You should calibrate the valve using the device Web UI or Shelly App before star
997997
| ------- | --------------- | -------- | --------- | ------------------------------------------------------------------- |
998998
| sensors | temperature | Number | yes | Current Temperature in °C |
999999
| | state | Contact | yes | Valve status: OPEN or CLOSED (position = 0) |
1000-
| | open | Contact | yes | ON: "window is open" was detected, OFF: window is closed |
10011000
| | lastUpdate | DateTime | yes | Timestamp of the last update (any sensor value changed) |
10021001
| control | targetTemp | Number | no | Temperature in °C: 4=Low/Min; 5..30=target temperature;31=Hi/Max |
10031002
| | position | Dimmer | no | Set valve to manual mode (0..100%) disables auto-temp) |

bundles/org.openhab.binding.shelly/src/main/java/org/openhab/binding/shelly/internal/ShellyBindingConstants.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ public class ShellyBindingConstants {
291291
public static final String SHELLY_API_MIN_FWCOIOT = "v1.6";// v1.6.0+
292292
public static final String SHELLY_API_FWCOIOT2 = "v1.8";// CoAP 2 with FW 1.8+
293293
public static final String SHELLY_API_FW_110 = "v1.10"; // FW 1.10 or newer detected, activates some add feature
294-
public static final String SHELLY2_API_MIN_FWVERSION = "v0.10.2"; // Gen 2 minimum FW
294+
public static final String SHELLY2_API_MIN_FWVERSION = "v0.10.1"; // Gen 2 minimum FW
295295

296296
// Alarm types/messages
297297
public static final String ALARM_TYPE_NONE = "NONE";
@@ -327,7 +327,7 @@ public class ShellyBindingConstants {
327327
public static final int DIGITS_LUX = 0;
328328
public static final int DIGITS_PERCENT = 1;
329329

330-
public static final int SHELLY_API_TIMEOUT_MS = 15000;
330+
public static final int SHELLY_API_TIMEOUT_MS = 10000;
331331
public static final int UPDATE_STATUS_INTERVAL_SECONDS = 3; // check for updates every x sec
332332
public static final int UPDATE_SKIP_COUNT = 20; // update every x triggers or when a key was pressed
333333
public static final int UPDATE_MIN_DELAY = 15;// update every x triggers or when a key was pressed

bundles/org.openhab.binding.shelly/src/main/java/org/openhab/binding/shelly/internal/api/ShellyApiResult.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public class ShellyApiResult {
3333
public String response = "";
3434
public int httpCode = -1;
3535
public String httpReason = "";
36-
public String authResponse = "";
36+
public String authChallenge = "";
3737

3838
public ShellyApiResult() {
3939
}

bundles/org.openhab.binding.shelly/src/main/java/org/openhab/binding/shelly/internal/api/ShellyHttpClient.java

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,21 @@
1414

1515
import static org.openhab.binding.shelly.internal.ShellyBindingConstants.SHELLY_API_TIMEOUT_MS;
1616
import static org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.*;
17+
import static org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.*;
1718
import static org.openhab.binding.shelly.internal.util.ShellyUtils.*;
1819

1920
import java.nio.charset.StandardCharsets;
21+
import java.text.MessageFormat;
2022
import java.util.Base64;
2123
import java.util.Map;
2224
import java.util.concurrent.ExecutionException;
2325
import java.util.concurrent.TimeUnit;
2426
import java.util.concurrent.TimeoutException;
2527

28+
import javax.ws.rs.core.HttpHeaders;
29+
2630
import org.eclipse.jdt.annotation.NonNullByDefault;
31+
import org.eclipse.jdt.annotation.Nullable;
2732
import org.eclipse.jetty.client.HttpClient;
2833
import org.eclipse.jetty.client.api.ContentResponse;
2934
import org.eclipse.jetty.client.api.Request;
@@ -32,6 +37,8 @@
3237
import org.eclipse.jetty.http.HttpHeader;
3338
import org.eclipse.jetty.http.HttpMethod;
3439
import org.eclipse.jetty.http.HttpStatus;
40+
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2AuthChallenge;
41+
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2AuthRsp;
3542
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2RpcBaseMessage;
3643
import org.openhab.binding.shelly.internal.config.ShellyThingConfiguration;
3744
import org.openhab.binding.shelly.internal.handler.ShellyThingInterface;
@@ -49,8 +56,9 @@
4956
public class ShellyHttpClient {
5057
private final Logger logger = LoggerFactory.getLogger(ShellyHttpClient.class);
5158

52-
public static final String HTTP_HEADER_AUTH = "Authorization";
59+
public static final String HTTP_HEADER_AUTH = HttpHeaders.AUTHORIZATION;
5360
public static final String HTTP_AUTH_TYPE_BASIC = "Basic";
61+
public static final String HTTP_AUTH_TYPE_DIGEST = "Digest";
5462
public static final String CONTENT_TYPE_JSON = "application/json; charset=UTF-8";
5563
public static final String CONTENT_TYPE_FORM_URLENC = "application/x-www-form-urlencoded";
5664

@@ -72,6 +80,7 @@ public ShellyHttpClient(String thingName, ShellyThingConfiguration config, HttpC
7280
this.thingName = thingName;
7381
setConfig(thingName, config);
7482
this.httpClient = httpClient;
83+
this.httpClient.setConnectTimeout(SHELLY_API_TIMEOUT_MS);
7584
}
7685

7786
public void initialize() throws ShellyApiException {
@@ -103,7 +112,7 @@ protected String httpRequest(String uri) throws ShellyApiException {
103112
boolean timeout = false;
104113
while (retries > 0) {
105114
try {
106-
apiResult = innerRequest(HttpMethod.GET, uri, "");
115+
apiResult = innerRequest(HttpMethod.GET, uri, null, "");
107116
if (timeout) {
108117
logger.debug("{}: API timeout #{}/{} recovered ({})", thingName, timeoutErrors, timeoutsRecovered,
109118
apiResult.getUrl());
@@ -128,10 +137,15 @@ protected String httpRequest(String uri) throws ShellyApiException {
128137
}
129138

130139
public String httpPost(String uri, String data) throws ShellyApiException {
131-
return innerRequest(HttpMethod.POST, uri, data).response;
140+
return innerRequest(HttpMethod.POST, uri, null, data).response;
141+
}
142+
143+
public String httpPost(@Nullable Shelly2AuthChallenge auth, String data) throws ShellyApiException {
144+
return innerRequest(HttpMethod.POST, SHELLYRPC_ENDPOINT, auth, data).response;
132145
}
133146

134-
private ShellyApiResult innerRequest(HttpMethod method, String uri, String data) throws ShellyApiException {
147+
private ShellyApiResult innerRequest(HttpMethod method, String uri, @Nullable Shelly2AuthChallenge auth,
148+
String data) throws ShellyApiException {
135149
Request request = null;
136150
String url = "http://" + config.deviceIp + uri;
137151
ShellyApiResult apiResult = new ShellyApiResult(method.toString(), url);
@@ -140,10 +154,24 @@ private ShellyApiResult innerRequest(HttpMethod method, String uri, String data)
140154
request = httpClient.newRequest(url).method(method.toString()).timeout(SHELLY_API_TIMEOUT_MS,
141155
TimeUnit.MILLISECONDS);
142156

143-
if (!config.password.isEmpty() && !getString(data).contains("\"auth\":{")) {
144-
String value = config.userId + ":" + config.password;
145-
request.header(HTTP_HEADER_AUTH,
146-
HTTP_AUTH_TYPE_BASIC + " " + Base64.getEncoder().encodeToString(value.getBytes()));
157+
if (!uri.equals(SHELLY_URL_DEVINFO) && !config.password.isEmpty()) { // not for /shelly or no password
158+
// configured
159+
// Add Auth info
160+
// Gen 1: Basic Auth
161+
// Gen 2: Digest Auth
162+
String authHeader = "";
163+
if (auth != null) { // only if we received an Auth challenge
164+
authHeader = formatAuthResponse(uri,
165+
buildAuthResponse(uri, auth, SHELLY2_AUTHDEF_USER, config.password));
166+
} else {
167+
if (!uri.equals(SHELLYRPC_ENDPOINT)) {
168+
String bearer = config.userId + ":" + config.password;
169+
authHeader = HTTP_AUTH_TYPE_BASIC + " " + Base64.getEncoder().encodeToString(bearer.getBytes());
170+
}
171+
}
172+
if (!authHeader.isEmpty()) {
173+
request.header(HTTP_HEADER_AUTH, authHeader);
174+
}
147175
}
148176
fillPostData(request, data);
149177
logger.trace("{}: HTTP {} for {} {}\n{}", thingName, method, url, data, request.getHeaders());
@@ -162,14 +190,14 @@ private ShellyApiResult innerRequest(HttpMethod method, String uri, String data)
162190
apiResult.httpCode = message.error.code;
163191
apiResult.response = message.error.message;
164192
if (getInteger(message.error.code) == HttpStatus.UNAUTHORIZED_401) {
165-
apiResult.authResponse = getString(message.error.message).replaceAll("\\\"", "\"");
193+
apiResult.authChallenge = getString(message.error.message).replaceAll("\\\"", "\"");
166194
}
167195
}
168196
}
169197
HttpFields headers = contentResponse.getHeaders();
170-
String auth = headers.get(HttpHeader.WWW_AUTHENTICATE);
171-
if (!getString(auth).isEmpty()) {
172-
apiResult.authResponse = auth;
198+
String authChallenge = headers.get(HttpHeader.WWW_AUTHENTICATE);
199+
if (!getString(authChallenge).isEmpty()) {
200+
apiResult.authChallenge = authChallenge;
173201
}
174202

175203
// validate response, API errors are reported as Json
@@ -191,6 +219,36 @@ private ShellyApiResult innerRequest(HttpMethod method, String uri, String data)
191219
return apiResult;
192220
}
193221

222+
protected @Nullable Shelly2AuthRsp buildAuthResponse(String uri, @Nullable Shelly2AuthChallenge challenge,
223+
String user, String password) throws ShellyApiException {
224+
if (challenge == null) {
225+
return null; // not required
226+
}
227+
if (!SHELLY2_AUTHTTYPE_DIGEST.equalsIgnoreCase(challenge.authType)
228+
|| !SHELLY2_AUTHALG_SHA256.equalsIgnoreCase(challenge.algorithm)) {
229+
throw new IllegalArgumentException("Unsupported Auth type/algorithm requested by device");
230+
}
231+
Shelly2AuthRsp response = new Shelly2AuthRsp();
232+
response.username = user;
233+
response.realm = challenge.realm;
234+
response.nonce = challenge.nonce;
235+
response.cnonce = Long.toHexString((long) Math.floor(Math.random() * 10e8));
236+
response.nc = "00000001";
237+
response.authType = challenge.authType;
238+
response.algorithm = challenge.algorithm;
239+
String ha1 = sha256(response.username + ":" + response.realm + ":" + password);
240+
String ha2 = sha256(HttpMethod.POST + ":" + uri);// SHELLY2_AUTH_NOISE;
241+
response.response = sha256(
242+
ha1 + ":" + response.nonce + ":" + response.nc + ":" + response.cnonce + ":" + "auth" + ":" + ha2);
243+
return response;
244+
}
245+
246+
protected String formatAuthResponse(String uri, @Nullable Shelly2AuthRsp rsp) {
247+
return rsp != null ? MessageFormat.format(HTTP_AUTH_TYPE_DIGEST
248+
+ " username=\"{0}\", realm=\"{1}\", uri=\"{2}\", nonce=\"{3}\", cnonce=\"{4}\", nc=\"{5}\", qop=\"auth\",response=\"{6}\", algorithm=\"{7}\", ",
249+
rsp.username, rsp.realm, uri, rsp.nonce, rsp.cnonce, rsp.nc, rsp.response, rsp.algorithm) : "";
250+
}
251+
194252
/**
195253
* Fill in POST data, set http headers
196254
*

bundles/org.openhab.binding.shelly/src/main/java/org/openhab/binding/shelly/internal/api2/Shelly2ApiClient.java

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,7 @@
5252
import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellyStatusSensor.ShellySensorBat;
5353
import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellyStatusSensor.ShellySensorHum;
5454
import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellyStatusSensor.ShellySensorLux;
55-
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2AuthRequest;
56-
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2AuthResponse;
55+
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2AuthRsp;
5756
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2DeviceConfig.Shelly2DevConfigCover;
5857
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2DeviceConfig.Shelly2DevConfigInput;
5958
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2DeviceConfig.Shelly2DevConfigSwitch;
@@ -92,7 +91,7 @@ public class Shelly2ApiClient extends ShellyHttpClient {
9291
protected final ShellyStatusSensor sensorData = new ShellyStatusSensor();
9392
protected final ArrayList<ShellyRollerStatus> rollerStatus = new ArrayList<>();
9493
protected @Nullable ShellyThingInterface thing;
95-
protected @Nullable Shelly2AuthRequest authReq;
94+
protected @Nullable Shelly2AuthRsp authReq;
9695

9796
public Shelly2ApiClient(String thingName, ShellyThingInterface thing) {
9897
super(thingName, thing);
@@ -793,23 +792,6 @@ protected Shelly2RpcBaseMessage buildRequest(String method, @Nullable Object par
793792
return request;
794793
}
795794

796-
protected Shelly2AuthRequest buildAuthRequest(Shelly2AuthResponse authParm, String user, String realm,
797-
String password) throws ShellyApiException {
798-
Shelly2AuthRequest authReq = new Shelly2AuthRequest();
799-
authReq.username = "admin";
800-
authReq.realm = realm;
801-
authReq.nonce = authParm.nonce;
802-
authReq.cnonce = (long) Math.floor(Math.random() * 10e8);
803-
authReq.nc = authParm.nc != null ? authParm.nc : 1;
804-
authReq.authType = SHELLY2_AUTHTTYPE_DIGEST;
805-
authReq.algorithm = SHELLY2_AUTHALG_SHA256;
806-
String ha1 = sha256(authReq.username + ":" + authReq.realm + ":" + password);
807-
String ha2 = SHELLY2_AUTH_NOISE;
808-
authReq.response = sha256(
809-
ha1 + ":" + authReq.nonce + ":" + authReq.nc + ":" + authReq.cnonce + ":" + "auth" + ":" + ha2);
810-
return authReq;
811-
}
812-
813795
protected String mapValue(Map<String, String> map, @Nullable String key) {
814796
String value;
815797
boolean known = key != null && !key.isEmpty() && map.containsKey(key);

bundles/org.openhab.binding.shelly/src/main/java/org/openhab/binding/shelly/internal/api2/Shelly2ApiJsonDTO.java

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
* @author Markus Michels - Initial contribution
2828
*/
2929
public class Shelly2ApiJsonDTO {
30+
public static final String SHELLYRPC_ENDPOINT = "/rpc";
31+
3032
public static final String SHELLYRPC_METHOD_CLASS_SHELLY = "Shelly";
3133
public static final String SHELLYRPC_METHOD_CLASS_SWITCH = "Switch";
3234

@@ -1004,7 +1006,7 @@ public class Shelly2RpcMessageError {
10041006
public Object params;
10051007
public String event;
10061008
public Object result;
1007-
public Shelly2AuthRequest auth;
1009+
public Shelly2AuthRsp auth;
10081010
public Shelly2RpcMessageError error;
10091011
}
10101012

@@ -1022,31 +1024,32 @@ public static class Shelly2NotifyStatus extends Shelly2DeviceStatusResult {
10221024
public Shelly2RpcMessageError error;
10231025
}
10241026

1027+
public static String SHELLY2_AUTHDEF_USER = "admin";
10251028
public static String SHELLY2_AUTHTTYPE_DIGEST = "digest";
10261029
public static String SHELLY2_AUTHTTYPE_STRING = "string";
10271030
public static String SHELLY2_AUTHALG_SHA256 = "SHA-256";
10281031
// = ':auth:'+HexHash("dummy_method:dummy_uri");
10291032
public static String SHELLY2_AUTH_NOISE = "6370ec69915103833b5222b368555393393f098bfbfbb59f47e0590af135f062";
10301033

1031-
public static class Shelly2AuthRequest {
1032-
public String username;
1033-
public Long nonce;
1034-
public Long cnonce;
1035-
public Integer nc;
1036-
public String realm;
1037-
public String algorithm;
1038-
public String response;
1034+
public static class Shelly2AuthChallenge { // on 401 message contains the auth info
10391035
@SerializedName("auth_type")
10401036
public String authType;
1037+
public String nonce;
1038+
public String nc;
1039+
public String realm;
1040+
public String algorithm;
10411041
}
10421042

1043-
public static class Shelly2AuthResponse { // on 401 message contains the auth info
1044-
@SerializedName("auth_type")
1045-
public String authType;
1046-
public Long nonce;
1047-
public Integer nc;
1043+
public static class Shelly2AuthRsp {
1044+
public String username;
1045+
public String nonce;
1046+
public String cnonce;
1047+
public String nc;
10481048
public String realm;
10491049
public String algorithm;
1050+
public String response;
1051+
@SerializedName("auth_type")
1052+
public String authType;
10501053
}
10511054

10521055
// BTHome samples

0 commit comments

Comments
 (0)