diff --git a/README.md b/README.md index 9156e4f..a776357 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,6 @@ The Duo Universal Client for Java is available from Duo Security on Maven. Incl ``` See https://mvnrepository.com/artifact/com.duosecurity/duo-universal-sdk/1.0.3 for more details. -This SDK sets the `use_duo_code_attribute` attribute to `true`, which forces the the authorization code will -be returned under the attribute name of `duo_code`. See [this link](https://duo.com/docs/oauthapi) for more info. - # Demo ## Build diff --git a/duo-example/src/main/java/com/duosecurity/controller/LoginController.java b/duo-example/src/main/java/com/duosecurity/controller/LoginController.java index b9b51ff..3f04d89 100644 --- a/duo-example/src/main/java/com/duosecurity/controller/LoginController.java +++ b/duo-example/src/main/java/com/duosecurity/controller/LoginController.java @@ -39,11 +39,23 @@ public class LoginController { private Client duoClient; - // Step 1: Construct the duoClient object upon class creation + /** + * Create and initialize the Duo Client. + * + * @throws DuoException For problems creating the Clients + */ @PostConstruct public void initializeDuoClient() throws DuoException { stateMap = new HashMap<>(); - duoClient = new Client(clientId, clientSecret, apiHost, redirectUri); + duoClient = new Client.Builder(clientId, clientSecret, apiHost, redirectUri).build(); + + /* Example of setting optional fields + duoClient = new Client.Builder(clientId, clientSecret, apiHost, redirectUri) + .setUseDuoCodeAttribute(false) + .setCACerts(customCerts) + .appendUserAgentInfo("custom string") + .build(); + */ } @RequestMapping(value = "/", method = RequestMethod.GET) diff --git a/duo-universal-sdk/src/main/java/com/duosecurity/Client.java b/duo-universal-sdk/src/main/java/com/duosecurity/Client.java index fe3d936..07767ce 100644 --- a/duo-universal-sdk/src/main/java/com/duosecurity/Client.java +++ b/duo-universal-sdk/src/main/java/com/duosecurity/Client.java @@ -43,70 +43,180 @@ public class Client { // ************************************************** // Fields // ************************************************** - private final String clientId; + private String clientId; - private final String clientSecret; + private String clientSecret; - private final String apiHost; + private String apiHost; - private final String redirectUri; + private String redirectUri; + + private Boolean useDuoCodeAttribute; protected DuoConnector duoConnector; private String userAgent; - private static final String[] DEFAULT_CA_CERTS = { - //C=US, O=DigiCert Inc, OU=www.digicert.com, CN=DigiCert Assured ID Root CA - "sha256/I/Lt/z7ekCWanjD0Cvj5EqXls2lOaThEA0H2Bg4BT/o=", - //C=US, O=DigiCert Inc, OU=www.digicert.com, CN=DigiCert Global Root CA - "sha256/r/mIkG3eEpVdm+u/ko/cwxzOMo1bk4TyHIlByibiA5E=", - //C=US, O=DigiCert Inc, OU=www.digicert.com, CN=DigiCert High Assurance EV Root CA - "sha256/WoiWRyIOVNa9ihaBciRSC7XHjliYS9VwUGOIud4PB18=", - //C=US, O=SecureTrust Corporation, CN=SecureTrust CA - "sha256/dykHF2FLJfEpZOvbOLX4PKrcD2w2sHd/iA/G3uHTOcw=", - //C=US, O=SecureTrust Corporation, CN=Secure Global CA - "sha256/JZaQTcTWma4gws703OR/KFk313RkrDcHRvUt6na6DCg=", - //C=US, O=Amazon, CN=Amazon Root CA 1 - "sha256/++MBgDH5WGvL9Bcn5Be30cRcL0f5O+NyoXuWtQdX1aI=", - //C=US, O=Amazon, CN=Amazon Root CA 2 - "sha256/f0KW/FtqTjs108NpYj42SrGvOB2PpxIVM8nWxjPqJGE=", - //C=US, O=Amazon, CN=Amazon Root CA 3 - "sha256/NqvDJlas/GRcYbcWE8S/IceH9cq77kg0jVhZeAPXq8k=", - //C=US, O=Amazon, CN=Amazon Root CA 4 - "sha256/9+ze1cZgR9KO1kZrVDxA4HQ6voHRCSVNz4RdTCx4U8U=", - //C=BM, O=QuoVadis Limited, CN=QuoVadis Root CA 2 - "sha256/j9ESw8g3DxR9XM06fYZeuN1UB4O6xp/GAIjjdD/zM3g=" - }; - // ************************************************** // Constructors + // This class uses the "Builder" pattern and should not be directly instantiated. + // Use Client.Builder to generate the Client. // ************************************************** + + private Client() { + } + /** - * Main constructor. - * - * @param clientId This value is the client id provided by Duo in the admin panel. - * @param clientSecret This value is the client secret provided by Duo in the admin panel. - * @param apiHost This value is the api host provided by Duo in the admin panel. - * @param redirectUri This value is the uri which Duo should redirect to after 2FA is completed. - * @param userCaCerts This value is a list of CA Certificates used to validate connections to Duo. - * - * @throws DuoException For problems validating the client parameters + * Legacy simple constructor. + * @deprecated The constructors are deprecated. + * Prefer the {@link Client.Builder} for instantiating Clients */ + @Deprecated + public Client(String clientId, String clientSecret, String apiHost, String redirectUri) + throws DuoException { + this(clientId, clientSecret, apiHost, redirectUri, null); + } + + /** + * Legacy constructor which allows specifying custom CaCerts. + * @deprecated The constructors are deprecated. + * Prefer the {@link Client.Builder} for instantiating Clients + */ + @Deprecated public Client(String clientId, String clientSecret, String apiHost, - String redirectUri, String[] userCaCerts) throws DuoException { - validateClientParams(clientId, clientSecret, apiHost, redirectUri); - this.clientId = clientId; - this.clientSecret = clientSecret; - this.apiHost = apiHost; - this.redirectUri = redirectUri; - this.userAgent = computeUserAgent(); - String[] caCerts = validateCaCert(userCaCerts) ? userCaCerts : DEFAULT_CA_CERTS; - duoConnector = new DuoConnector(apiHost, caCerts); + String redirectUri, String[] userCaCerts) throws DuoException { + Client client = new Builder(clientId, clientSecret, apiHost, redirectUri) + .setCACerts(userCaCerts) + .build(); + this.clientId = client.clientId; + this.clientSecret = client.clientSecret; + this.apiHost = client.apiHost; + this.redirectUri = client.redirectUri; + this.useDuoCodeAttribute = client.useDuoCodeAttribute; + this.duoConnector = client.duoConnector; + this.userAgent = client.userAgent; } - public Client(String clientId, String clientSecret, String apiHost, String redirectUri) - throws DuoException { - this(clientId, clientSecret, apiHost, redirectUri, DEFAULT_CA_CERTS); + public static class Builder { + private final String clientId; + private final String clientSecret; + private final String apiHost; + private final String redirectUri; + private Boolean useDuoCodeAttribute; + private String[] caCerts; + private String userAgent; + + private static final String[] DEFAULT_CA_CERTS = { + //C=US, O=DigiCert Inc, OU=www.digicert.com, CN=DigiCert Assured ID Root CA + "sha256/I/Lt/z7ekCWanjD0Cvj5EqXls2lOaThEA0H2Bg4BT/o=", + //C=US, O=DigiCert Inc, OU=www.digicert.com, CN=DigiCert Global Root CA + "sha256/r/mIkG3eEpVdm+u/ko/cwxzOMo1bk4TyHIlByibiA5E=", + //C=US, O=DigiCert Inc, OU=www.digicert.com, CN=DigiCert High Assurance EV Root CA + "sha256/WoiWRyIOVNa9ihaBciRSC7XHjliYS9VwUGOIud4PB18=", + //C=US, O=SecureTrust Corporation, CN=SecureTrust CA + "sha256/dykHF2FLJfEpZOvbOLX4PKrcD2w2sHd/iA/G3uHTOcw=", + //C=US, O=SecureTrust Corporation, CN=Secure Global CA + "sha256/JZaQTcTWma4gws703OR/KFk313RkrDcHRvUt6na6DCg=", + //C=US, O=Amazon, CN=Amazon Root CA 1 + "sha256/++MBgDH5WGvL9Bcn5Be30cRcL0f5O+NyoXuWtQdX1aI=", + //C=US, O=Amazon, CN=Amazon Root CA 2 + "sha256/f0KW/FtqTjs108NpYj42SrGvOB2PpxIVM8nWxjPqJGE=", + //C=US, O=Amazon, CN=Amazon Root CA 3 + "sha256/NqvDJlas/GRcYbcWE8S/IceH9cq77kg0jVhZeAPXq8k=", + //C=US, O=Amazon, CN=Amazon Root CA 4 + "sha256/9+ze1cZgR9KO1kZrVDxA4HQ6voHRCSVNz4RdTCx4U8U=", + //C=BM, O=QuoVadis Limited, CN=QuoVadis Root CA 2 + "sha256/j9ESw8g3DxR9XM06fYZeuN1UB4O6xp/GAIjjdD/zM3g=" + }; + + /** + * Builder. + * + * @param clientId This value is the client id provided by Duo in the admin panel. + * @param clientSecret This value is the client secret provided by Duo in the admin panel. + * @param apiHost This value is the api host provided by Duo in the admin panel. + * @param redirectUri This value is the uri which Duo should redirect to after 2FA is completed. + * + * @throws DuoException For problems validating the client parameters + */ + public Builder(String clientId, String clientSecret, String apiHost, + String redirectUri) { + this.clientId = clientId; + this.clientSecret = clientSecret; + this.apiHost = apiHost; + this.redirectUri = redirectUri; + this.caCerts = DEFAULT_CA_CERTS; + this.useDuoCodeAttribute = true; + this.userAgent = computeUserAgent(); + } + + /** + * Build the client object. + * + * @return {@link Client} + * + * @throws DuoException For problems building the client + */ + public Client build() throws DuoException { + validateClientParams(clientId, clientSecret, apiHost, redirectUri); + + Client client = new Client(); + client.clientId = clientId; + client.clientSecret = clientSecret; + client.apiHost = apiHost; + client.redirectUri = redirectUri; + client.useDuoCodeAttribute = useDuoCodeAttribute; + client.userAgent = userAgent; + client.duoConnector = new DuoConnector(apiHost, caCerts); + + return client; + } + + /** + * Optionally use custom CA Certificates when validating connections to Duo. + * + * @param userCaCerts List of CA Certificates to use + */ + public Builder setCACerts(String[] userCaCerts) { + if (validateCaCert(userCaCerts)) { + this.caCerts = userCaCerts; + } + return this; + } + + /** + * Optionally toggle the returned authorization parameter to use duo_code vs code. + * Defaults true to use duo_code. + * + * @param useDuoCodeAttribute true/false toggle + */ + public Builder setUseDuoCodeAttribute(boolean useDuoCodeAttribute) { + this.useDuoCodeAttribute = useDuoCodeAttribute; + return this; + } + + /** + * Optionally appends string to userAgent. + * + * @param newUserAgent Additional info that will be added to the end of the user agent string + */ + public Builder appendUserAgentInfo(String newUserAgent) { + userAgent = format("%s %s", userAgent, newUserAgent); + return this; + } + + private String computeUserAgent() { + String duoAgent = format("%s/%s", USER_AGENT_LIB, USER_AGENT_VERSION); + String javaAgent = format("%s/%s", + System.getProperty("java.vendor"), + System.getProperty("java.version")); + String osAgent = format("%s/%s/%s", + System.getProperty("os.name"), + System.getProperty("os.version"), + System.getProperty("os.arch")); + + return format("%s %s %s", duoAgent, javaAgent, osAgent); + } } // ************************************************** @@ -145,7 +255,8 @@ public HealthCheckResponse healthCheck() throws DuoException { public String createAuthUrl(String username, String state) throws DuoException { validateUsername(username); validateState(state); - String request = createJwtForAuthUrl(clientId, clientSecret, redirectUri, state, username); + String request = createJwtForAuthUrl(clientId, clientSecret, redirectUri, + state, username, useDuoCodeAttribute); String query = format( "?scope=openid&response_type=code&redirect_uri=%s&client_id=%s&request=%s", redirectUri, clientId, request); @@ -222,26 +333,4 @@ public String generateState() { return Utils.generateJwtId(36); } - private String computeUserAgent() { - String duoAgent = format("%s/%s", USER_AGENT_LIB, USER_AGENT_VERSION); - String javaAgent = format("%s/%s", - System.getProperty("java.vendor"), - System.getProperty("java.version")); - String osAgent = format("%s/%s/%s", - System.getProperty("os.name"), - System.getProperty("os.version"), - System.getProperty("os.arch")); - - return format("%s %s %s", duoAgent, javaAgent, osAgent); - } - - /** - * Appends string to userAgent. - * - * @param newUserAgent Additional info that will be added to the end - * of the user agent string - */ - public void appendUserAgentInfo(String newUserAgent) { - userAgent = format("%s %s", userAgent, newUserAgent); - } } diff --git a/duo-universal-sdk/src/main/java/com/duosecurity/Utils.java b/duo-universal-sdk/src/main/java/com/duosecurity/Utils.java index 1d819b5..60efd72 100644 --- a/duo-universal-sdk/src/main/java/com/duosecurity/Utils.java +++ b/duo-universal-sdk/src/main/java/com/duosecurity/Utils.java @@ -48,7 +48,8 @@ static String createJwt(String clientId, String clientSecret, String aud) throws } static String createJwtForAuthUrl(String clientId, String clientSecret, String redirectUri, - String state, String username) throws DuoException { + String state, String username, + Boolean useDuoCodeAttribute) throws DuoException { Date expiration = new Date(); expiration.setTime(expiration.getTime() + ONE_HOUR_IN_MILLISECONDS); try { @@ -61,7 +62,7 @@ static String createJwtForAuthUrl(String clientId, String clientSecret, String r .withClaim("state", state) .withClaim("duo_uname", username) .withClaim("response_type", "code") - .withClaim("use_duo_code_attribute", "True") + .withClaim("use_duo_code_attribute", useDuoCodeAttribute) .sign(Algorithm.HMAC512(clientSecret)); } catch (UnsupportedEncodingException e) { throw new DuoException(e.getMessage(), e); diff --git a/duo-universal-sdk/src/test/java/com/duosecurity/ClientTest.java b/duo-universal-sdk/src/test/java/com/duosecurity/ClientTest.java index 78fdf1c..b412b61 100644 --- a/duo-universal-sdk/src/test/java/com/duosecurity/ClientTest.java +++ b/duo-universal-sdk/src/test/java/com/duosecurity/ClientTest.java @@ -7,10 +7,13 @@ import com.duosecurity.model.Token; import com.duosecurity.model.TokenResponse; import com.duosecurity.service.DuoConnector; +import okhttp3.HttpUrl; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.Mockito; +import org.mockito.internal.matchers.apachecommons.ReflectionEquals; import java.net.MalformedURLException; import java.net.URL; @@ -18,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Matchers.any; import static org.mockito.Matchers.anyString; +import static org.mockito.Mockito.verify; class ClientTest { @@ -32,7 +36,7 @@ class ClientTest { @BeforeEach void setUp() throws DuoException { - this.client = new Client(CLIENT_ID, CLIENT_SECRET, API_HOST, HTTPS_REDIRECT_URI); + this.client = new Client.Builder(CLIENT_ID, CLIENT_SECRET, API_HOST, HTTPS_REDIRECT_URI).build(); client.duoConnector = Mockito.mock(DuoConnector.class); } @@ -113,7 +117,7 @@ void createAuthUrl_throws_exception_for_invalid_state() { @Test void createAuthUrl_throws_exception_for_invalid_client_id() throws DuoException { try { - Client badClient = new Client("", CLIENT_SECRET, API_HOST, HTTPS_REDIRECT_URI); + Client badClient = new Client.Builder("", CLIENT_SECRET, API_HOST, HTTPS_REDIRECT_URI).build(); badClient.createAuthUrl(USERNAME, STATE); Assertions.fail(); } catch (DuoException e) { @@ -124,7 +128,7 @@ void createAuthUrl_throws_exception_for_invalid_client_id() throws DuoException @Test void createAuthUrl_throws_exception_for_invalid_client_secret() throws DuoException { try { - Client badClient = new Client(CLIENT_ID, "", API_HOST, HTTPS_REDIRECT_URI); + Client badClient = new Client.Builder(CLIENT_ID, "", API_HOST, HTTPS_REDIRECT_URI).build(); badClient.createAuthUrl(USERNAME, STATE); Assertions.fail(); } catch (DuoException e) { @@ -135,7 +139,7 @@ void createAuthUrl_throws_exception_for_invalid_client_secret() throws DuoExcept @Test void createAuthUrl_throws_exception_for_invalid_api_host() throws DuoException { try { - Client badClient = new Client(CLIENT_ID, CLIENT_SECRET, "", HTTPS_REDIRECT_URI); + Client badClient = new Client.Builder(CLIENT_ID, CLIENT_SECRET, "", HTTPS_REDIRECT_URI).build(); badClient.createAuthUrl(USERNAME, STATE); Assertions.fail(); } catch (DuoException e) { @@ -146,7 +150,7 @@ void createAuthUrl_throws_exception_for_invalid_api_host() throws DuoException { @Test void createAuthUrl_throws_exception_for_invalid_redirect_uri() throws DuoException { try { - Client badClient = new Client(CLIENT_ID, CLIENT_SECRET, API_HOST, ""); + Client badClient = new Client.Builder(CLIENT_ID, CLIENT_SECRET, API_HOST, "").build(); badClient.createAuthUrl(USERNAME, STATE); Assertions.fail(); } catch (DuoException e) { @@ -175,11 +179,75 @@ public DecodedJWT validateAndDecode(String jwt) throws DuoException { @Test void exchangeAuthorizationCodeFor2FAResult_throws_exception_for_invalid_api_host() throws DuoException { try { - Client badClient = new Client(CLIENT_ID, CLIENT_SECRET, "", HTTPS_REDIRECT_URI); + Client badClient = new Client.Builder(CLIENT_ID, CLIENT_SECRET, "", HTTPS_REDIRECT_URI).build(); badClient.exchangeAuthorizationCodeFor2FAResult("duo_code", "username"); Assertions.fail(); } catch (DuoException e) { assertTrue(e.getMessage().contains("Invalid host")); } } + + @Test + void useDuoCodeAttribute_defaults_true() throws DuoException { + // Don't rely on setUp Client just in case that ever changes to longform constructor + Client client = new Client.Builder(CLIENT_ID, CLIENT_SECRET, API_HOST, HTTPS_REDIRECT_URI).build(); + String urlString = client.createAuthUrl(USERNAME, STATE); + HttpUrl url = HttpUrl.parse(urlString); + String jwtString = url.queryParameter("request"); + DecodedJWT jwt = JWT.decode(jwtString); + Boolean use_code = jwt.getClaim("use_duo_code_attribute").asBoolean(); + assertTrue(use_code); + } + + @Test + void useDuoCodeAttribute_set_false() throws DuoException { + Client client = new Client.Builder(CLIENT_ID, CLIENT_SECRET, API_HOST, HTTPS_REDIRECT_URI) + .setUseDuoCodeAttribute(false) + .build(); + String urlString = client.createAuthUrl(USERNAME, STATE); + HttpUrl url = HttpUrl.parse(urlString); + String jwtString = url.queryParameter("request"); + DecodedJWT jwt = JWT.decode(jwtString); + Boolean use_code = jwt.getClaim("use_duo_code_attribute").asBoolean(); + assertFalse(use_code); + } + + @Test + void custom_useragent() throws DuoException { + String appendedUserAgent = "foobar"; + + Client client = new Client.Builder(CLIENT_ID, CLIENT_SECRET, API_HOST, HTTPS_REDIRECT_URI) + .appendUserAgentInfo(appendedUserAgent) + .build(); + + client.duoConnector = Mockito.mock(DuoConnector.class); + + try { + client.exchangeAuthorizationCodeFor2FAResult("duo_code", Mockito.mock(TokenValidator.class)); + } + catch(Exception e) { + // The calls will fail due to the incomplete mocking, but we're only interesting + // in the calling arguments, so this is fine and can be ignored. + } + + ArgumentCaptor stringCaptor = ArgumentCaptor.forClass(String.class); + verify(client.duoConnector).exchangeAuthorizationCodeFor2FAResult(stringCaptor.capture(), anyString(), anyString(), anyString(), anyString(), anyString()); + String sentUserAgent = stringCaptor.getValue(); + assertTrue(sentUserAgent.startsWith("duo_universal_java") && sentUserAgent.endsWith(appendedUserAgent)); + } + + @Test + void legacy_constructors_match() throws DuoException { + // Create clients using the old deprecated constructors and check that their fields are the same as one created using the builder. + // This should help prevent adding a new field to the class and forgetting to update the legacy constructors. + // Unfortunately duoConnector is an object entity that can't be compared. + + Client builderClient = new Client.Builder(CLIENT_ID, CLIENT_SECRET, API_HOST, HTTPS_REDIRECT_URI).build(); + Client shortConstructorClient = new Client(CLIENT_ID, CLIENT_SECRET, API_HOST, HTTPS_REDIRECT_URI); + Client longConstructorClient = new Client(CLIENT_ID, CLIENT_SECRET, API_HOST, HTTPS_REDIRECT_URI, null); + + assertTrue(new ReflectionEquals(builderClient, "duoConnector").matches(shortConstructorClient)); + assertTrue(new ReflectionEquals(builderClient, "duoConnector").matches(longConstructorClient)); + } + } diff --git a/duo-universal-sdk/src/test/java/com/duosecurity/UtilsTest.java b/duo-universal-sdk/src/test/java/com/duosecurity/UtilsTest.java index 873eef9..0db362b 100644 --- a/duo-universal-sdk/src/test/java/com/duosecurity/UtilsTest.java +++ b/duo-universal-sdk/src/test/java/com/duosecurity/UtilsTest.java @@ -60,7 +60,7 @@ void createJWT() throws DuoException { @Test void createJWTForAuthURL() throws DuoException { - String jwt = Utils.createJwtForAuthUrl("my_client_id", CLIENT_SECRET, "my_redirect_uri", "my_state", "my_username"); + String jwt = Utils.createJwtForAuthUrl("my_client_id", CLIENT_SECRET, "my_redirect_uri", "my_state", "my_username", true); // Just testing the transform logic so a simple decode is sufficient DecodedJWT decodedJWT = JWT.decode(jwt); assertEquals(decodedJWT.getClaim("client_id").asString(), "my_client_id");