diff --git a/doc/images/zoho-001.png b/doc/images/zoho-001.png new file mode 100644 index 0000000..9610fc6 Binary files /dev/null and b/doc/images/zoho-001.png differ diff --git a/doc/images/zoho-002.png b/doc/images/zoho-002.png new file mode 100644 index 0000000..7dbbc71 Binary files /dev/null and b/doc/images/zoho-002.png differ diff --git a/doc/zoho/README.md b/doc/zoho/README.md index d0cbf31..71dab2a 100644 --- a/doc/zoho/README.md +++ b/doc/zoho/README.md @@ -41,3 +41,50 @@ ee9ba49f16e77c64abb89cea13542d2326d8d03ce8 ## 3. Generate Access and Refresh Token https://accounts.zoho.com/oauth/v2/token?code=1000.dd7exxxxxxxxxxxxxxxxxxxxxxxx9bb8.b6c0xxxxxxxxxxxxxxxxxxxxxxxxdca4&client_id=1000.NLTM2KUEHI696MSLGEIANATVGGX7IN&client_secret=ee9ba49f16e77c64abb89cea13542d2326d8d03ce8&redirect_uri=http://www.zoho.com/books&grant_type=authorization_code + +# Self Client + +Man kann bei zoho mit einem sogenannten 'Self Client' arbeiten. Dieser erlaubt es ohne Redirect auf die Zoho Webseite einen Code anzufordern: + +## 1. Self Client einrichten + +Auf die API COnsole von Zoho zugreifen: + +https://api-console.zoho.eu/client/1000.GR5FTPZYB06HEWSFSSX4DJU3OBD4EJ + + + +Es kann maximal ein Client eingerichtet werden + +Unter Scope trägt man dann den gewünschten Scope ein. Z.B.: + + ZohoBooks.invoices.CREATE,ZohoBooks.invoices.READ,ZohoBooks.invoices.UPDATE,ZohoBooks.invoices.DELETE + +und eine Description + + + +Den generierten Code nun in Anwendung übertragen und innerhalb (!!) der 'Time Duration' den Access und Refresh Token generieren! + +Siehe Junit Test `TestUpdateTokens` + +Find details also [here](https://www.zoho.com/writer/help/api/v1/oauth-step2.html#self-client) + +## Example for JUnit Test + +Der Junit Test `TestUpdateTokens` zeigt das Prinzip + +``` +client ID= 1000.GR5FTPZYB06HEWSFSSX4DJU3OBD4EJ +client secret= 3f3e74c5212bbc924a7ebf77f887c6296db1106c47 +``` + +In der Web GUi einen neuen Code generieren: + +Scope=ZohoBooks.invoices.CREATE,ZohoBooks.invoices.READ,ZohoBooks.invoices.UPDATE,ZohoBooks.invoices.DELETE + +Danach den Code token kopieren: + +1000.7fe6315e8848b92a2f281b885677d37a.428c02dc2309606c86271842f1f67de4 + +**Note:** Der Code muss für Test immer wieder neu erstellt werden, da er nur für einige Minuten gültig ist! diff --git a/doc/zoho/image.png b/doc/zoho/image.png new file mode 100644 index 0000000..d0a4e76 Binary files /dev/null and b/doc/zoho/image.png differ diff --git a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/zoho/TokenResponse.java b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/zoho/TokenResponse.java new file mode 100644 index 0000000..66cb23e --- /dev/null +++ b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/zoho/TokenResponse.java @@ -0,0 +1,74 @@ +package com.alexanderlogistics.zoho; + +/** + * Helper class for JSON deserialization + * + * If the request is successful, you would receive the following: + * + *
+    {
+    "access_token": "{access_token}",
+    "refresh_token": "{refresh_token}",
+    "api_domain": "https://www.zohoapis.com",
+    "token_type": "Bearer",
+    "expires_in": 3600
+    }
+ * 
+ */ +public class TokenResponse { + private String access_token; + private String refresh_token; + private long expires_in; + private String api_domain; + private String token_type; + private String scope; // Neu hinzugefügt für dein Response-Feld + + // Getter/Setter notwendig für Yasson + public String getAccess_token() { + return access_token; + } + + public void setAccess_token(String access_token) { + this.access_token = access_token; + } + + public String getRefresh_token() { + return refresh_token; + } + + public void setRefresh_token(String refresh_token) { + this.refresh_token = refresh_token; + } + + public long getExpires_in() { + return expires_in; + } + + public void setExpires_in(long expires_in) { + this.expires_in = expires_in; + } + + public String getApi_domain() { + return api_domain; + } + + public void setApi_domain(String api_domain) { + this.api_domain = api_domain; + } + + public String getToken_type() { + return token_type; + } + + public void setToken_type(String token_type) { + this.token_type = token_type; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } +} diff --git a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/zoho/ZohoAPIService.java b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/zoho/ZohoAPIService.java new file mode 100644 index 0000000..1be7114 --- /dev/null +++ b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/zoho/ZohoAPIService.java @@ -0,0 +1,40 @@ +package com.alexanderlogistics.zoho; + +import jakarta.annotation.security.DeclareRoles; +import jakarta.annotation.security.RolesAllowed; +import jakarta.annotation.security.RunAs; +import jakarta.ejb.Lock; +import jakarta.ejb.LockType; +import jakarta.ejb.Singleton; + +/** + * Der ZohoAPIService stellt methoden für den Zugriff auf die Zoho API bereit. + * Er handelt auch die Authentifizeirung über einen Access Token. Der + * ZohoOAuhtGrantService der dazu dient einen AccessCode von Zoho zu ermitteln + * übergibt den code an diesesn Servcie. Er dietn also als Singelton + */ +@DeclareRoles({ "org.imixs.ACCESSLEVEL.NOACCESS", "org.imixs.ACCESSLEVEL.READERACCESS", + "org.imixs.ACCESSLEVEL.AUTHORACCESS", "org.imixs.ACCESSLEVEL.EDITORACCESS", + "org.imixs.ACCESSLEVEL.MANAGERACCESS" }) +@RolesAllowed({ "org.imixs.ACCESSLEVEL.NOACCESS", "org.imixs.ACCESSLEVEL.READERACCESS", + "org.imixs.ACCESSLEVEL.AUTHORACCESS", "org.imixs.ACCESSLEVEL.EDITORACCESS", + "org.imixs.ACCESSLEVEL.MANAGERACCESS" }) +@Singleton +@RunAs("org.imixs.ACCESSLEVEL.MANAGERACCESS") +public class ZohoAPIService { + + private String accessToken; + private String clientID; + private String clientSecret; + + @Lock(LockType.WRITE) // Standardmäßig ist @Lock(LockType.READ) + public void setAccessToken(String token) { + this.accessToken = token; + } + + @Lock(LockType.READ) // Erlaubt parallele Lesezugriffe + public String getAccessToken() { + return accessToken; + } + +} diff --git a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/zoho/ZohoOAuthGrantService.java b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/zoho/ZohoOAuthGrantService.java index fb1e1f1..33c5544 100644 --- a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/zoho/ZohoOAuthGrantService.java +++ b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/zoho/ZohoOAuthGrantService.java @@ -32,11 +32,9 @@ import java.util.logging.Logger; import org.imixs.workflow.exceptions.QueryException; -import jakarta.annotation.Resource; -import jakarta.ejb.SessionContext; +import jakarta.ejb.EJB; import jakarta.ejb.Stateless; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; @@ -69,11 +67,8 @@ public class ZohoOAuthGrantService { @Context private HttpServletRequest servletRequest; - @Context - HttpServletResponse servletResponse; - - @Resource - private SessionContext ctx; + @EJB + ZohoOAuthManager zohoOAuthManager; private static Logger logger = Logger.getLogger(ZohoOAuthGrantService.class.getName()); @@ -122,6 +117,8 @@ public class ZohoOAuthGrantService { logger.info("│ ├── location=" + location); logger.info("│ ├── accounts-server=" + accountsServer); + zohoOAuthManager.updateTokens(code); + return "Zoho Grant Token received!"; } diff --git a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/zoho/ZohoOAuthManager.java b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/zoho/ZohoOAuthManager.java new file mode 100644 index 0000000..f918417 --- /dev/null +++ b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/zoho/ZohoOAuthManager.java @@ -0,0 +1,149 @@ +package com.alexanderlogistics.zoho; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Optional; +import java.util.logging.Logger; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.imixs.workflow.exceptions.InvalidAccessException; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.security.RunAs; +import jakarta.ejb.Lock; +import jakarta.ejb.LockType; +import jakarta.ejb.Singleton; +import jakarta.inject.Inject; +import jakarta.json.bind.Jsonb; +import jakarta.json.bind.JsonbBuilder; + +/** + * Der ZohoOAuthManager stellt Methoden bereit um einen Access Token zu + * erstellen. + * + * + */ +@Singleton +@RunAs("org.imixs.ACCESSLEVEL.MANAGERACCESS") +public class ZohoOAuthManager { + + private static Logger logger = Logger.getLogger(ZohoOAuthManager.class.getName()); + + private String refreshToken; + private String accessToken; + private long accessTokenExpiry; // Timestamp (System.currentTimeMillis() + expires_in*1000) + + @Inject + @ConfigProperty(name = "zoho.client.id", defaultValue = "1000.NLTM2KUEHI696MSLGEIANATVGGX7IN") + Optional clientID; + + @Inject + @ConfigProperty(name = "zoho.client.secret", defaultValue = "ee9ba49f16e77c64abb89cea13542d2326d8d03ce8") + Optional clientSecret; + + private HttpClient httpClient; + private Jsonb jsonb; + + @PostConstruct + void init() { + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + this.jsonb = JsonbBuilder.create(); + } + + /** + * Step 3: Generate Access and Refresh Token + * + * After getting code from the GrantService we make a POST request + * + * See description: https://www.zoho.com/books/api/v3/oauth/ + * + * + * @param code + */ + @Lock(LockType.WRITE) + public void updateTokens(String code) { + + logger.info("├── update Zoho tokens"); + if (!clientID.isPresent()) { + throw new InvalidAccessException("Missing client ID"); + } + if (!clientSecret.isPresent()) { + throw new InvalidAccessException("Missing client secret"); + } + try { + + String redirectURI = "https://alexander-logistics-dwc.office-workflow.de/api/zoho/grant"; + + // Korrekte URL-Konstruktion mit Query-Parametern + // String requestUrl = "https://accounts.zoho.com/oauth/v2/token" + + // "?code=" + URLEncoder.encode(code, StandardCharsets.UTF_8) + + // "&client_id=" + URLEncoder.encode(clientID.get(), StandardCharsets.UTF_8) + + // "&client_secret=" + URLEncoder.encode(clientSecret.get(), + // StandardCharsets.UTF_8) + + // "&redirect_uri=" + URLEncoder.encode("http://www.zoho.com/books", + // StandardCharsets.UTF_8) + + // "&grant_type=authorization_code"; + + String requestUrl = "https://accounts.zoho.eu/oauth/v2/token" + + "?code=" + URLEncoder.encode(code, StandardCharsets.UTF_8) + + "&client_id=" + URLEncoder.encode(clientID.get(), StandardCharsets.UTF_8) + + "&client_secret=" + URLEncoder.encode(clientSecret.get(), StandardCharsets.UTF_8) + + "&redirect_uri=" + URLEncoder.encode(redirectURI, StandardCharsets.UTF_8) + + "&grant_type=authorization_code"; + + logger.info("│ ├── Request URI=" + requestUrl); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(requestUrl)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.noBody()) // Wichtig: Kein Body! + .build(); + + HttpResponse response; + + response = httpClient.send(request, + HttpResponse.BodyHandlers.ofString()); + + logger.info("│ ├── Request statusCode=" + response.statusCode()); + if (response.statusCode() == 200) { + + // 1. Response-Logging für Debugging + logger.info("│ ├── Request statusCode=" + response.body()); + + TokenResponse tokenResponse = jsonb.fromJson(response.body(), + TokenResponse.class); + + this.accessToken = tokenResponse.getAccess_token(); + logger.info("│ ├── accessToken=" + accessToken); + this.refreshToken = tokenResponse.getRefresh_token(); + logger.info("│ ├── refreshToken=" + refreshToken); + this.accessTokenExpiry = System.currentTimeMillis() + + (tokenResponse.getExpires_in() * 1000); + + logger.info("│ ├── expires_in=" + tokenResponse.getExpires_in()); + } else { + throw new RuntimeException("Failed to get tokens: " + + response.statusCode() + " - " + response.body()); + } + + } catch (IOException | InterruptedException e) { + throw new InvalidAccessException("Unable to resolve access tokens: " + e.getMessage()); + } + + } + + @Lock(LockType.READ) + public String getValidAccessToken() { + + return null; + } + +} diff --git a/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/zoho/TestUpdateTokens.java b/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/zoho/TestUpdateTokens.java new file mode 100644 index 0000000..202ff8f --- /dev/null +++ b/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/zoho/TestUpdateTokens.java @@ -0,0 +1,111 @@ +package com.alexanderlogistics.zoho; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.logging.Logger; + +import org.imixs.workflow.exceptions.InvalidAccessException; +import org.imixs.workflow.exceptions.ModelException; +import org.imixs.workflow.exceptions.PluginException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import jakarta.json.bind.Jsonb; +import jakarta.json.bind.JsonbBuilder; + +/** + * Diese Klasse generiert einen access und refresh token anhand eines Code + * Tokens + * + * + * @author rsoika + */ +public class TestUpdateTokens { + + final static String ENCODING = "UTF-8"; + + private static Logger logger = Logger.getLogger(TestUpdateTokens.class.getName()); + + private HttpClient httpClient; + private Jsonb jsonb; + + private String code = "1000.a6cff79178a76099a625eb41249a6c91.b3679ff9365902502d2e3ca55303d5d6"; + + private String clientID = "1000.GR5FTPZYB06HEWSFSSX4DJU3OBD4EJ"; + private String clientSecret = "3f3e74c5212bbc924a7ebf77f887c6296db1106c47"; + + @BeforeEach + public void setup() throws PluginException, ModelException { + logger.info("setup test environment ..."); + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + this.jsonb = JsonbBuilder.create(); + } + + /** + * Testet den Vorgang einen Access und Refresh Token zu generieren + * + * @throws IOException + */ + @Test + public void testUpdateTokens() throws IOException { + + logger.info("├── update Zoho tokens"); + + try { + + String redirectURI = "https://alexander-logistics-dwc.office-workflow.de/api/zoho/grant"; + + String requestUrl = "https://accounts.zoho.eu/oauth/v2/token" + + "?code=" + URLEncoder.encode(code, StandardCharsets.UTF_8) + + "&client_id=" + URLEncoder.encode(clientID, StandardCharsets.UTF_8) + + "&client_secret=" + URLEncoder.encode(clientSecret, StandardCharsets.UTF_8) + + "&redirect_uri=" + URLEncoder.encode(redirectURI, StandardCharsets.UTF_8) + + "&grant_type=authorization_code"; + + logger.info("│ ├── Request URI=" + requestUrl); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(requestUrl)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.noBody()) // Wichtig: Kein Body! + .build(); + + HttpResponse response; + + response = httpClient.send(request, + HttpResponse.BodyHandlers.ofString()); + + logger.info("│ ├── Request statusCode=" + response.statusCode()); + if (response.statusCode() == 200) { + + // 1. Response-Logging für Debugging + logger.info("│ ├── Request statusCode=" + response.body()); + + TokenResponse tokenResponse = jsonb.fromJson(response.body(), + TokenResponse.class); + + String accessToken = tokenResponse.getAccess_token(); + logger.info("│ ├── accessToken=" + accessToken); + String refreshToken = tokenResponse.getRefresh_token(); + logger.info("│ ├── refreshToken=" + refreshToken); + logger.info("│ ├── expires_in=" + tokenResponse.getExpires_in()); + } else { + throw new RuntimeException("Failed to get tokens: " + + response.statusCode() + " - " + response.body()); + } + + } catch (IOException | InterruptedException e) { + throw new InvalidAccessException("Unable to resolve access tokens: " + e.getMessage()); + } + + } + +} diff --git a/pom.xml b/pom.xml index 6015710..0a56237 100644 --- a/pom.xml +++ b/pom.xml @@ -326,5 +326,28 @@ test + + + org.eclipse + yasson + 3.0.3 + test + + + org.glassfish + jakarta.json + 2.0.1 + test + + + + + org.apache.httpcomponents.client5 + httpclient5 + 5.2.1 + test + + + \ No newline at end of file