Start refactoring AuthService to handle direct Enedis service call and cleaner code

Signed-off-by: Laurent ARNAL <laurent@clae.net>
This commit is contained in:
Laurent ARNAL 2024-04-17 15:38:29 +02:00
parent cc827a0166
commit 73d36c30a7
7 changed files with 168 additions and 212 deletions

View File

@ -35,7 +35,7 @@ public interface LinkyAccountHandler {
* @param reqCode The unique code passed by Smartthings to obtain the refresh and access tokens
* @return returns the name of the Smartthings user that is authorized
*/
String authorize(String redirectUrl, String reqCode) throws LinkyException;
String authorize(String redirectUrl, String reqState, String reqCode) throws LinkyException;
/**
* Formats the Url to use to call Smartthings to authorize the application.

View File

@ -17,18 +17,22 @@ import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Hashtable;
import java.util.Map;
import java.util.List;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
import org.openhab.core.auth.client.oauth2.OAuthClientService;
import org.openhab.core.auth.client.oauth2.OAuthException;
import org.openhab.core.auth.client.oauth2.OAuthFactory;
import org.openhab.core.auth.client.oauth2.OAuthResponseException;
import org.osgi.framework.BundleContext;
import org.osgi.service.component.ComponentContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.http.HttpService;
@ -41,28 +45,38 @@ import org.slf4j.LoggerFactory;
* @author Gaël L'hopital - Initial contribution
* @author Laurent Arnal - Rewrite addon to use official dataconect API
*/
@Component(service = LinkyAuthService.class, configurationPid = "binding.internal.authService")
@NonNullByDefault
public class LinkyAuthService {
public class LinkyAuthService implements LinkyAccountHandler {
private static final String TEMPLATE_PATH = "templates/";
private static final String TEMPLATE_INDEX = TEMPLATE_PATH + "index.html";
private static final String ERROR_UKNOWN_BRIDGE = "Returned 'state' by doesn't match any Bridges. Has the bridge been removed?";
private final Logger logger = LoggerFactory.getLogger(LinkyAuthService.class);
private @NonNullByDefault({}) HttpService httpService;
private @NonNullByDefault({}) BundleContext bundleContext;
private @Nullable LinkyAccountHandler accountHandler;
@Activate
protected void activate(ComponentContext componentContext, Map<String, Object> properties) {
private final boolean oAuthSupport = true;
private @Nullable OAuthClientService oAuthService;
public LinkyAuthService(final @Reference OAuthFactory oAuthFactory, final @Reference HttpService httpService,
final @Reference ComponentContext componentContext) {
this.httpService = httpService;
this.oAuthService = oAuthFactory.createOAuthClientService(LinkyBindingConstants.BINDING_ID,
LinkyBindingConstants.ENEDIS_API_TOKEN_URL, LinkyBindingConstants.ENEDIS_AUTH_AUTHORIZE_URL,
LinkyBindingConstants.clientId, LinkyBindingConstants.clientSecret, LinkyBindingConstants.LINKY_SCOPES,
true);
try {
bundleContext = componentContext.getBundleContext();
httpService.registerServlet(LinkyBindingConstants.LINKY_ALIAS, createServlet(), new Hashtable<>(),
httpService.createDefaultHttpContext());
httpService.registerResources(LinkyBindingConstants.LINKY_ALIAS + LinkyBindingConstants.LINKY_IMG_ALIAS,
"web", null);
} catch (NamespaceException | ServletException | IOException e) {
logger.warn("Error during linky servlet startup", e);
}
@ -104,54 +118,123 @@ public class LinkyAuthService {
}
}
/**
* Call with Linky redirect uri returned State and Code values to get the refresh and access tokens and persist
* these values
*
* @param servletBaseURL the servlet base, which will be the Linky redirect url
* @param state The Linky returned state value
* @param code The Linky returned code value
* @return returns the name of the Linky user that is authorized
*/
public String authorize(String servletBaseURL, String state, String code) throws LinkyException {
LinkyAccountHandler accountHandler = getLinkyAccountHandler();
if (accountHandler == null) {
logger.debug(
"Linky redirected with state '{}' but no matching bridge was found. Possible bridge has been removed.",
state);
throw new LinkyException(ERROR_UKNOWN_BRIDGE);
} else {
return accountHandler.authorize(servletBaseURL, code);
@Override
public String authorize(String redirectUri, String reqState, String reqCode) throws LinkyException {
// Will work only in case of direct oAuth2 authentification to enedis
// this is not the case in v1 as we go trough MyElectricalData
if (oAuthSupport) {
try {
OAuthClientService oAuthService = this.oAuthService;
if (oAuthService == null) {
throw new OAuthException("OAuth service is not initialized");
}
logger.debug("Make call to Enedis to get access token.");
final AccessTokenResponse credentials = oAuthService
.getAccessTokenByClientCredentials(LinkyBindingConstants.LINKY_SCOPES);
final String user = updateProperties(credentials);
logger.debug("Authorized for user: {}", user);
return user;
} catch (RuntimeException | OAuthException | IOException e) {
// updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
throw new LinkyException("Error during oAuth authorize :" + e.getMessage(), e);
} catch (final OAuthResponseException e) {
throw new LinkyException("\"Error during oAuth authorize :" + e.getMessage(), e);
}
}
// Fallback for MyElectricalData
else {
/*
* String token = EnedisHttpApi.getToken(httpClient, LinkyBindingConstants.clientId, reqCode);
*
* logger.debug("token: {}", token);
*
* Collection<Thing> col = this.thingRegistry.getAll();
* for (Thing thing : col) {
* if (LinkyBindingConstants.THING_TYPE_LINKY.equals(thing.getThingTypeUID())) {
* Configuration config = thing.getConfiguration();
* String prmId = (String) config.get("prmId");
*
* if (!prmId.equals(reqCode)) {
* continue;
* }
*
* config.put("token", token);
* LinkyHandler handler = (LinkyHandler) thing.getHandler();
* if (handler != null) {
* handler.saveConfiguration(config);
* }
* }
* }
*
* return token;
*/
return "";
}
}
/**
* @param listener Adds the given handler
*/
public void setLinkyAccountHandler(LinkyAccountHandler accountHandler) {
this.accountHandler = accountHandler;
@Override
public boolean isAuthorized() {
final AccessTokenResponse accessTokenResponse = getAccessTokenResponse();
return accessTokenResponse != null && accessTokenResponse.getAccessToken() != null
&& accessTokenResponse.getRefreshToken() != null;
}
/**
* @param handler Removes the given handler
*/
public void unsetLinkyAccountHandler(LinkyAccountHandler accountHandler) {
this.accountHandler = null;
private @Nullable AccessTokenResponse getAccessTokenResponse() {
try {
OAuthClientService oAuthService = this.oAuthService;
return oAuthService == null ? null : oAuthService.getAccessTokenResponse();
} catch (OAuthException | IOException | OAuthResponseException | RuntimeException e) {
logger.debug("Exception checking authorization: ", e);
return null;
}
}
/**
* @param listener Adds the given handler
*/
public @Nullable LinkyAccountHandler getLinkyAccountHandler() {
return this.accountHandler;
private String updateProperties(AccessTokenResponse credentials) {
return credentials.getAccessToken();
}
@Reference
protected void setHttpService(HttpService httpService) {
this.httpService = httpService;
@Override
public String formatAuthorizationUrl(String redirectUri) {
// Will work only in case of direct oAuth2 authentification to enedis
// this is not the case in v1 as we go trough MyElectricalData
if (oAuthSupport) {
try {
OAuthClientService oAuthService = this.oAuthService;
if (oAuthService == null) {
throw new OAuthException("OAuth service is not initialized");
}
String uri = oAuthService.getAuthorizationUrl(redirectUri, null, "Linky");
return uri;
} catch (final OAuthException e) {
logger.debug("Error constructing AuthorizationUrl: ", e);
return "";
}
}
return "";
}
protected void unsetHttpService(HttpService httpService) {
this.httpService = null;
@Override
public List<String> getAllPrmId() {
List<String> result = new ArrayList<String>();
/*
* Collection<Thing> col = this.thingRegistry.getAll();
*
* for (Thing thing : col) {
* if (LinkyBindingConstants.THING_TYPE_LINKY.equals(thing.getThingTypeUID())) {
* Configuration config = thing.getConfiguration();
*
* String prmId = (String) config.get("prmId");
* result.add(prmId);
* }
* }
*/
return result;
}
}

View File

@ -83,25 +83,19 @@ public class LinkyAuthServlet extends HttpServlet {
try {
handleLinkyRedirect(replaceMap, servletBaseURLSecure, req.getQueryString());
LinkyAccountHandler accountHandler = linkyAuthService.getLinkyAccountHandler();
resp.setContentType(CONTENT_TYPE);
StringBuffer optionBuffer = new StringBuffer();
if (accountHandler != null) {
List<String> prmIds = accountHandler.getAllPrmId();
for (String prmId : prmIds) {
optionBuffer.append("<option value=\"" + prmId + "\">" + prmId + "</option>");
}
List<String> prmIds = linkyAuthService.getAllPrmId();
for (String prmId : prmIds) {
optionBuffer.append("<option value=\"" + prmId + "\">" + prmId + "</option>");
}
replaceMap.put(KEY_PRMID_OPTION, optionBuffer.toString());
replaceMap.put(KEY_REDIRECT_URI, servletBaseURLSecure);
replaceMap.put(KEY_RETRIEVE_TOKEN_URI, servletBaseURLSecure + "?state=OK");
if (accountHandler != null) {
replaceMap.put(KEY_AUTHORIZE_URI, accountHandler.formatAuthorizationUrl(servletBaseURLSecure));
}
replaceMap.put(KEY_AUTHORIZE_URI, linkyAuthService.formatAuthorizationUrl(servletBaseURLSecure));
resp.getWriter().append(replaceKeysFromMap(indexTemplate, replaceMap));
resp.getWriter().close();
} catch (LinkyException ex) {

View File

@ -30,6 +30,9 @@ public class LinkyBindingConstants {
public static final String BINDING_ID = "linky";
public static final String clientId = "_88uJnEjEs_IMf4bjGZJV6gGxYga";
public static final String clientSecret = "6lsPfCmu0fEXuKYy3e0e6w8ydIca";
// List of all Thing Type UIDs
public static final ThingTypeUID THING_TYPE_LINKY = new ThingTypeUID(BINDING_ID, "linky");
@ -69,16 +72,20 @@ public class LinkyBindingConstants {
/**
* Smartthings scopes needed by this binding to work.
*/
public static final String LINKY_SCOPES = Stream.of("r:devices:*", "w:devices:*", "x:devices:*", "r:hubs:*",
"r:locations:*", "w:locations:*", "x:locations:*", "r:scenes:*", "x:scenes:*", "r:rules:*", "w:rules:*",
"r:installedapps", "w:installedapps").collect(Collectors.joining(" "));
public static final String LINKY_SCOPES = Stream.of("am_application_scope", "default")
.collect(Collectors.joining(" "));
// "r:devices:*", "w:devices:*", "x:devices:*", "r:hubs:*",
// "r:locations:*", "w:locations:*", "x:locations:*", "r:scenes:*", "x:scenes:*", "r:rules:*", "w:rules:*",
// "r:installedapps", "w:installedapps"
// List of Linky services related urls, information
public static final String LINKY_ACCOUNT_URL = "https://www.myelectricaldata.fr/";
public static final String LINKY_AUTHORIZE_URL = LINKY_ACCOUNT_URL + "v1/oauth2/authorize";
public static final String LINKY_API_TOKEN_URL = LINKY_ACCOUNT_URL + "token";
public static final String ENEDIS_ACCOUNT_URL = "https://mon-compte-particulier.enedis.fr/";
public static final String ENEDIS_AUTHORIZE_URL = ENEDIS_ACCOUNT_URL + "dataconnect/v1/oauth2/authorize";
public static final String ENEDIS_API_TOKEN_URL = ENEDIS_ACCOUNT_URL + "token";
public static final String ENEDIS_API_ACCOUNT_URL = "https://ext.prod-sandbox.api.enedis.fr/";
public static final String ENEDIS_API_TOKEN_URL = ENEDIS_API_ACCOUNT_URL + "oauth2/v3/token";
public static final String ENEDIS_AUTH_ACCOUNT_URL = "https://mon-compte-particulier.enedis.fr/";
public static final String ENEDIS_AUTH_AUTHORIZE_URL = ENEDIS_AUTH_ACCOUNT_URL + "dataconnect/v1/oauth2/authorize";
}

View File

@ -25,6 +25,8 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
public class LinkyConfiguration {
public String token = "";
public String prmId = "";
public String clientId = "";
public String clientSecret = "";
public boolean seemsValid() {
return !prmId.isBlank();

View File

@ -12,16 +12,12 @@
*/
package org.openhab.binding.linky.internal;
import java.io.IOException;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
@ -30,19 +26,12 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.openhab.binding.linky.internal.api.EnedisHttpApi;
import org.openhab.binding.linky.internal.handler.LinkyHandler;
import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
import org.openhab.core.auth.client.oauth2.OAuthClientService;
import org.openhab.core.auth.client.oauth2.OAuthException;
import org.openhab.core.auth.client.oauth2.OAuthFactory;
import org.openhab.core.auth.client.oauth2.OAuthResponseException;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.io.net.http.TrustAllTrustManager;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingRegistry;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.openhab.core.thing.binding.ThingHandler;
@ -51,6 +40,7 @@ import org.osgi.service.component.ComponentContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.http.HttpService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -67,7 +57,7 @@ import com.google.gson.JsonDeserializer;
*/
@NonNullByDefault
@Component(service = ThingHandlerFactory.class, configurationPid = "binding.linky")
public class LinkyHandlerFactory extends BaseThingHandlerFactory implements LinkyAccountHandler {
public class LinkyHandlerFactory extends BaseThingHandlerFactory {
private static final DateTimeFormatter LINKY_FORMATTER = DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSSX");
private static final DateTimeFormatter LINKY_LOCALDATE_FORMATTER = DateTimeFormatter.ofPattern("uuuu-MM-dd");
private static final DateTimeFormatter LINKY_LOCALDATETIME_FORMATTER = DateTimeFormatter
@ -75,9 +65,6 @@ public class LinkyHandlerFactory extends BaseThingHandlerFactory implements Link
private static final int REQUEST_BUFFER_SIZE = 8000;
private static final int RESPONSE_BUFFER_SIZE = 200000;
private final String clientId = "e551937c-5250-48bc-b4a6-2323af68db92";
private final String clientSecret = "";
private final Logger logger = LoggerFactory.getLogger(LinkyHandlerFactory.class);
private final Gson gson = new GsonBuilder()
.registerTypeAdapter(ZonedDateTime.class,
@ -100,43 +87,36 @@ public class LinkyHandlerFactory extends BaseThingHandlerFactory implements Link
private final LocaleProvider localeProvider;
private final HttpClient httpClient;
private final LinkyAuthService authService;
private final boolean oAuthSupport = false;
private @Nullable OAuthClientService oAuthService;
private final ThingRegistry thingRegistry;
@Activate
public LinkyHandlerFactory(final @Reference LocaleProvider localeProvider,
final @Reference HttpClientFactory httpClientFactory, final @Reference LinkyAuthService authService,
final @Reference OAuthFactory oAuthFactory, final @Reference ThingRegistry thingRegistry) {
final @Reference HttpClientFactory httpClientFactory, final @Reference OAuthFactory oAuthFactory,
final @Reference HttpService httpService, ComponentContext componentContext) {
this.localeProvider = localeProvider;
SslContextFactory sslContextFactory = new SslContextFactory.Client();
try {
SSLContext sslContext = SSLContext.getInstance("SSL");
sslContext.init(null, new TrustManager[] { TrustAllTrustManager.getInstance() }, null);
sslContextFactory.setSslContext(sslContext);
LinkyAuthService authService = new LinkyAuthService(oAuthFactory, httpService, componentContext);
} catch (NoSuchAlgorithmException e) {
logger.warn("An exception occurred while requesting the SSL encryption algorithm : '{}'", e.getMessage(),
e);
} catch (KeyManagementException e) {
logger.warn("An exception occurred while initialising the SSL context : '{}'", e.getMessage(), e);
}
this.thingRegistry = thingRegistry;
this.httpClient = httpClientFactory.createHttpClient(LinkyBindingConstants.BINDING_ID, sslContextFactory);
httpClient.setFollowRedirects(false);
httpClient.setRequestBufferSize(REQUEST_BUFFER_SIZE);
this.authService = authService;
this.oAuthService = oAuthFactory.createOAuthClientService(LinkyBindingConstants.BINDING_ID,
LinkyBindingConstants.LINKY_API_TOKEN_URL, LinkyBindingConstants.LINKY_AUTHORIZE_URL, clientId,
clientSecret, LinkyBindingConstants.LINKY_SCOPES, true);
this.authService.setLinkyAccountHandler(this);
}
@Override
protected void activate(ComponentContext componentContext) {
super.activate(componentContext);
try {
httpClient.start();
} catch (Exception e) {
logger.warn("Unable to start Jetty HttpClient {}", e.getMessage());
@ -168,124 +148,4 @@ public class LinkyHandlerFactory extends BaseThingHandlerFactory implements Link
return null;
}
// ===========================================================================
@Override
public boolean isAuthorized() {
final AccessTokenResponse accessTokenResponse = getAccessTokenResponse();
return accessTokenResponse != null && accessTokenResponse.getAccessToken() != null
&& accessTokenResponse.getRefreshToken() != null;
}
private @Nullable AccessTokenResponse getAccessTokenResponse() {
try {
OAuthClientService oAuthService = this.oAuthService;
return oAuthService == null ? null : oAuthService.getAccessTokenResponse();
} catch (OAuthException | IOException | OAuthResponseException | RuntimeException e) {
logger.debug("Exception checking authorization: ", e);
return null;
}
}
@Override
public String authorize(String redirectUri, String reqCode) throws LinkyException {
// Will work only in case of direct oAuth2 authentification to enedis
// this is not the case in v1 as we go trough MyElectricalData
if (oAuthSupport) {
try {
OAuthClientService oAuthService = this.oAuthService;
if (oAuthService == null) {
throw new OAuthException("OAuth service is not initialized");
}
logger.debug("Make call to Smartthings to get access token.");
final AccessTokenResponse credentials = oAuthService.getAccessTokenResponseByAuthorizationCode(reqCode,
redirectUri);
final String user = updateProperties(credentials);
logger.debug("Authorized for user: {}", user);
return user;
} catch (RuntimeException | OAuthException | IOException e) {
// updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
throw new LinkyException("Error during oAuth authorize :" + e.getMessage(), e);
} catch (final OAuthResponseException e) {
throw new LinkyException("\"Error during oAuth authorize :" + e.getMessage(), e);
}
}
// Fallback for MyElectricalData
else {
String token = EnedisHttpApi.getToken(httpClient, clientId, reqCode);
logger.debug("token: {}", token);
Collection<Thing> col = this.thingRegistry.getAll();
for (Thing thing : col) {
if (LinkyBindingConstants.THING_TYPE_LINKY.equals(thing.getThingTypeUID())) {
Configuration config = thing.getConfiguration();
String prmId = (String) config.get("prmId");
if (!prmId.equals(reqCode)) {
continue;
}
config.put("token", token);
LinkyHandler handler = (LinkyHandler) thing.getHandler();
if (handler != null) {
handler.saveConfiguration(config);
}
}
}
return token;
}
}
private String updateProperties(AccessTokenResponse credentials) {
return "";
}
@Override
public String formatAuthorizationUrl(String redirectUri) {
// Will work only in case of direct oAuth2 authentification to enedis
// this is not the case in v1 as we go trough MyElectricalData
if (oAuthSupport) {
try {
OAuthClientService oAuthService = this.oAuthService;
if (oAuthService == null) {
throw new OAuthException("OAuth service is not initialized");
}
String uri = oAuthService.getAuthorizationUrl(redirectUri, null, "Linky");
return uri;
} catch (final OAuthException e) {
logger.debug("Error constructing AuthorizationUrl: ", e);
return "";
}
}
// Fallback for MyElectricalData
else {
String uri = LinkyBindingConstants.ENEDIS_AUTHORIZE_URL;
uri = uri + "?";
uri = uri + "&client_id=" + clientId;
uri = uri + "&duration=" + "P36M";
uri = uri + "&response_type=" + "code";
return uri;
}
}
@Override
public List<String> getAllPrmId() {
Collection<Thing> col = this.thingRegistry.getAll();
List<String> result = new ArrayList<String>();
for (Thing thing : col) {
if (LinkyBindingConstants.THING_TYPE_LINKY.equals(thing.getThingTypeUID())) {
Configuration config = thing.getConfiguration();
String prmId = (String) config.get("prmId");
result.add(prmId);
}
}
return result;
}
}

View File

@ -35,6 +35,16 @@
<description>Your Enedis token (can be left empty, use the connection page to automatically fill it
http://youopenhab/connectlinky)</description>
</parameter>
<parameter name="clientId" type="text" required="false">
<label>clientId</label>
<description>Your Enedis clientId</description>
</parameter>
<parameter name="clientSecret" type="text" required="false">
<label>clientSecret</label>
<description>Your Enedis clientSecret</description>
</parameter>
</config-description>