Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ CDP Java Client
### Project description

A simple Java interface for the CDP Studio development platform that allows Java applications to interact with
CDP Applications - retrieve CDP Application structures and read-write object values. For more information
about CDP Studio see https://cdpstudio.com/.
CDP Applications - retrieve CDP Application structures, read-write object values, and authenticate to secured
applications. For more information about CDP Studio see https://cdpstudio.com/.

### Usage

Expand All @@ -18,7 +18,8 @@ described in the javadoc (https://www.javadoc.io/doc/com.cdptech/cdpclient/).

### Dependencies

* [Maven](https://maven.apache.org/) - Downloads necessary dependencies and builds the library.
* [Maven](https://maven.apache.org/) - Downloads necessary dependencies, including the protocol compiler (`protoc`)
that generates the StudioAPI classes, and builds the library.
* [Project Lombok](https://projectlombok.org/) plugin - Install it to your IDE when developing this library
or the auto-complete will not find some generated getters-setters.

Expand Down
39 changes: 36 additions & 3 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@

<groupId>com.cdptech</groupId>
<artifactId>cdpclient</artifactId>
<version>1.2.4</version>
<version>2.0.0</version>
<packaging>jar</packaging>

<name>${project.groupId}:${project.artifactId}</name>
<description>
A simple Java interface for the CDP Studio development platform that allows Java applications to interact with
CDP Applications - retrieve CDP Application structures and read-write object values.
CDP Applications - retrieve CDP Application structures, read-write object values, and authenticate to
secured applications.
For more information about CDP Studio see https://cdpstudio.com/.
</description>
<url>https://github.com/CDPTechnologies/JavaCDPClient</url>
Expand All @@ -20,7 +21,34 @@
</properties>

<build>
<extensions>
<!-- Populates ${os.detected.classifier} so protobuf-maven-plugin can fetch the right protoc binary. -->
<extension>
<groupId>kr.motd.maven</groupId>
<artifactId>os-maven-plugin</artifactId>
<version>1.7.1</version>
</extension>
</extensions>
<plugins>
<plugin>
<!-- Generates com.cdptech.cdpclient.proto.StudioAPI from src/main/proto/studioapi.proto at
build time, pinned to protoc 3.25.5 so codegen is hermetic and matches the protobuf-java
runtime version. -->
<groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<version>0.6.1</version>
<configuration>
<protocArtifact>com.google.protobuf:protoc:3.25.5:exe:${os.detected.classifier}</protocArtifact>
<attachProtoSources>false</attachProtoSources>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
Expand All @@ -37,6 +65,11 @@
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
Expand Down Expand Up @@ -107,7 +140,7 @@
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>3.21.5</version>
<version>3.25.5</version>
</dependency>
<dependency>
<groupId>org.java-websocket</groupId>
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/com/cdptech/cdpclient/AuthRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ class UserAuthResult {
private AuthResultCode code;
private String text;
private List<Credential> additionalCredentials = new ArrayList<>();
private List<String> rolesAssigned = new ArrayList<>();
}

@Data
class SuggestedUser {
private String username;
private String firstName;
private String lastName;
}

@Data
Expand All @@ -82,6 +90,8 @@ class CDPVersion {
String getSystemUseNotification();
/** State of the authentication */
UserAuthResult getAuthResult();
/** Users the application suggests choosing from for login */
List<SuggestedUser> getSuggestedUsers();

/**
* Method to call to accept the application and provide requested credentials.
Expand Down
16 changes: 15 additions & 1 deletion src/main/java/com/cdptech/cdpclient/AuthenticationProtocol.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class AuthenticationProtocol implements Protocol {
private Authenticator authenticator = new Authenticator();
private Transport transport;
private Runnable finishedCallback;
private String challenge;

AuthenticationProtocol(Transport transport, Runnable finishedCallback) {
this.transport = transport;
Expand All @@ -25,13 +26,21 @@ class AuthenticationProtocol implements Protocol {
public void parse(byte[] buf) {
try {
authenticator.updateUserAuthResult(AuthResponse.parseFrom(buf));
finishedCallback.run();
// The server asks for an EncryptedPassword response after the PasswordHash round. Answer it from
// the cached credentials.
StudioAPI.AuthRequest reissue = authenticator.encryptedPasswordReissueRequest(challenge);
if (reissue != null) {
transport.send(reissue.toByteArray());
} else {
finishedCallback.run();
}
} catch (InvalidProtocolBufferException e) {
throw new RuntimeException(e);
}
}

void authenticate(String challenge, Map<String, String> data) {
this.challenge = challenge;
StudioAPI.AuthRequest authMessage = authenticator.createAuthMessage(challenge, data);
if (authMessage == null) {
finishedCallback.run();
Expand All @@ -44,4 +53,9 @@ AuthRequest.UserAuthResult getUserAuthResult() {
return authenticator.getUserAuthResult();
}

/** Forget the credentials cached for this attempt (call once the attempt is granted). */
void clearCachedCredentials() {
authenticator.clearCachedCredentials();
}

}
142 changes: 133 additions & 9 deletions src/main/java/com/cdptech/cdpclient/Authenticator.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,35 @@
import com.cdptech.cdpclient.proto.StudioAPI.AuthResponse;
import com.google.protobuf.ByteString;

import javax.crypto.Cipher;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

class Authenticator {

private static final String ENCRYPTED_PASSWORD_TYPE = "EncryptedPassword";
private static final String PASSWORD_ENCRYPTION_PUBLIC_KEY_PARAM = "PasswordEncryptionPublicKey";
// The client encrypts with the server-supplied 2048-bit RSA public key. RSA/PKCS#1 v1.5 fits at most
// keyBytes - 11 = 245 bytes per block, so the plaintext is chunked at that size before encryption.
private static final int RSA_KEY_LENGTH_BITS = 2048;
private static final int RSA_MAX_CHUNK_SIZE = RSA_KEY_LENGTH_BITS / 8 - 11;

private AuthResponse authMessage;
private MessageDigest digest;
private AuthRequest.UserAuthResult userAuthResult;
private Map<String, String> lastCredentials;

Authenticator() {
try {
Expand All @@ -40,24 +56,113 @@ void updateUserAuthResult(AuthResponse authMessage) {
userAuthResult.setCode(getResultCode());
userAuthResult.setText(getResultText());
userAuthResult.setAdditionalCredentials(getAdditionalChallenges());
userAuthResult.setRolesAssigned(new ArrayList<>(authMessage.getRoleAssignedList()));
}

StudioAPI.AuthRequest createAuthMessage(String challenge, Map<String, String> data) {
if (!data.containsKey(AuthRequest.USER)) {
// Cache a copy of the credentials so a subsequent EncryptedPassword challenge can be answered without
// re-prompting. The caller owns the map and may scrub or reuse it after accept() returns, hence the copy.
lastCredentials = new HashMap<>(data);
String user = data.get(AuthRequest.USER);
if (user == null || user.isEmpty()) {
notifyOfMissingUserName();
return null;
}
String user = data.get(AuthRequest.USER);
StudioAPI.AuthRequest.Builder authRequest = StudioAPI.AuthRequest.newBuilder().setUserId(user);
if (data.containsKey(AuthRequest.PASSWORD)) {
String password = data.get(AuthRequest.PASSWORD);
if (password != null && !password.isEmpty()) {
addPasswordResponse(challenge, data, authRequest, user);
String encryptionPublicKey = getEncryptionPublicKey();
if (encryptionPublicKey != null) {
addEncryptedPasswordResponse(challenge, data, authRequest, encryptionPublicKey);
}
}
if (data.containsKey(AuthRequest.NEW_PASSWORD)) {
String newPassword = data.get(AuthRequest.NEW_PASSWORD);
if (newPassword != null && !newPassword.isEmpty()) {
addNewPasswordResponse(data, authRequest, user);
}
return authRequest.build();
}

/**
* The RSA public key (PEM) from the last AuthResponse's EncryptedPassword challenge, or null when the
* response carries no key.
*/
private String getEncryptionPublicKey() {
for (AuthRequest.Credential credential : userAuthResult.getAdditionalCredentials()) {
if (ENCRYPTED_PASSWORD_TYPE.equals(credential.getType())) {
return credential.getParameters().get(PASSWORD_ENCRYPTION_PUBLIC_KEY_PARAM);
}
}
return null;
}

private boolean hasEncryptionPublicKey() {
return getEncryptionPublicKey() != null;
}

/** Drop the cached credentials once the attempt is granted, so the password isn't retained for the connection's lifetime. */
void clearCachedCredentials() {
lastCredentials = null;
}

/**
* The AuthRequest that answers the server's EncryptedPassword request from the cached credentials and the
* given challenge. Null when the server sent no encryption key or no password is cached, and the user is
* prompted instead.
*/
StudioAPI.AuthRequest encryptedPasswordReissueRequest(String challenge) {
// An empty cached password cannot answer the challenge, and re-sending the request without a hash
// would repeat on every server response. The user is prompted.
if (hasEncryptionPublicKey() && hasCachedPassword()) {
return createAuthMessage(challenge, lastCredentials);
}
return null;
}

private boolean hasCachedPassword() {
if (lastCredentials == null) {
return false;
}
String password = lastCredentials.get(AuthRequest.PASSWORD);
return password != null && !password.isEmpty();
}

private void addEncryptedPasswordResponse(String challenge, Map<String, String> data,
StudioAPI.AuthRequest.Builder authRequest, String publicKeyPem) {
String password = data.get(AuthRequest.PASSWORD);
ChallengeResponse challengeResponse = ChallengeResponse.newBuilder()
.setType(ENCRYPTED_PASSWORD_TYPE)
.setResponse(ByteString.copyFrom(encryptPassword(challenge, password, publicKeyPem)))
.build();
authRequest.addChallengeResponse(challengeResponse);
}

/** RSA-encrypt {@code challenge + password} with the server-supplied key, chunked to fit PKCS#1 blocks. */
private byte[] encryptPassword(String challenge, String password, String publicKeyPem) {
byte[] data = (challenge + password).getBytes(StandardCharsets.UTF_8);
try {
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
cipher.init(Cipher.ENCRYPT_MODE, parseRsaPublicKey(publicKeyPem));
ByteArrayOutputStream encrypted = new ByteArrayOutputStream();
for (int offset = 0; offset < data.length; offset += RSA_MAX_CHUNK_SIZE) {
int length = Math.min(RSA_MAX_CHUNK_SIZE, data.length - offset);
encrypted.write(cipher.doFinal(data, offset, length));
}
return encrypted.toByteArray();
} catch (GeneralSecurityException | IOException e) {
throw new RuntimeException("Failed to RSA-encrypt password for EncryptedPassword authentication", e);
}
}

private PublicKey parseRsaPublicKey(String pem) throws GeneralSecurityException {
String base64 = pem.replaceAll("-----BEGIN [^-]*-----", "")
.replaceAll("-----END [^-]*-----", "")
.replaceAll("\\s", "");
byte[] der = Base64.getDecoder().decode(base64);
return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(der));
}

private void addPasswordResponse(String challenge, Map<String, String> data, StudioAPI.AuthRequest.Builder authRequest, String user) {
String password = data.get(AuthRequest.PASSWORD);
ChallengeResponse challengeResponse = ChallengeResponse.newBuilder()
Expand All @@ -79,17 +184,32 @@ private void addNewPasswordResponse(Map<String, String> data, StudioAPI.AuthRequ
private void notifyOfMissingUserName() {
userAuthResult = new AuthRequest.UserAuthResult();
userAuthResult.setCode(AuthRequest.AuthResultCode.USERNAME_REQUIRED);
userAuthResult.setText("Authentication failed: username not specified");
userAuthResult.setText("Username required");
}

private byte[] passwordHash(String user, String password) {
return digest.digest((user.toLowerCase() + ":" + password).getBytes());
return digest.digest((lowerCaseAscii(user) + ":" + password).getBytes(StandardCharsets.UTF_8));
}

/**
* Lower-cases ASCII 'A'-'Z' only. The hash the server compares against is computed from the
* ASCII-lowercased user name, so locale-sensitive or Unicode lowercasing (Turkish dotless i,
* 'Ö' to 'ö') would produce a mismatching digest.
*/
private static String lowerCaseAscii(String text) {
char[] chars = text.toCharArray();
for (int i = 0; i < chars.length; i++) {
if (chars[i] >= 'A' && chars[i] <= 'Z') {
chars[i] = (char) (chars[i] + ('a' - 'A'));
}
}
return new String(chars);
}

private byte[] challengeHash(String challenge, byte[] data) {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
try {
outputStream.write(challenge.getBytes());
outputStream.write(challenge.getBytes(StandardCharsets.UTF_8));
outputStream.write(':');
outputStream.write(data);
} catch (IOException e) {
Expand All @@ -103,9 +223,12 @@ AuthRequest.UserAuthResult getUserAuthResult() {
}

private AuthRequest.AuthResultCode getResultCode() {
// A result code this client does not know clears the field, so a missing code is a denial.
if (!authMessage.hasResultCode())
return AuthRequest.AuthResultCode.INVALID_CHALLENGE_RESPONSE;
switch (authMessage.getResultCode()) {
case eUnknown:
return AuthRequest.AuthResultCode.UNKNOWN;
case eCredentialsRequired:
return AuthRequest.AuthResultCode.CREDENTIALS_REQUIRED;
case eGranted:
return AuthRequest.AuthResultCode.GRANTED;
case eGrantedPasswordWillExpireSoon:
Expand Down Expand Up @@ -137,6 +260,7 @@ private List<AuthRequest.Credential> getAdditionalChallenges() {
for (StudioAPI.AdditionalChallengeResponseRequired.Parameter parameter : item.getParameterList()) {
c.getParameters().put(parameter.getName(), parameter.getValue());
}
challenges.add(c);
}
return challenges;
}
Expand Down
Loading