From 5b9e67efcd10e23ae410d62c7202f0df69264315 Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 28 Oct 2024 09:46:06 +0000 Subject: [PATCH 01/62] Changed: throwing an error in BearerTokenAuthMechanismTest when token is validated but there is no registered user account --- .../api/auth/BearerTokenAuthMechanism.java | 19 +++++++++---------- .../api/auth/WrappedAuthErrorResponse.java | 17 +++++++++++++++-- .../auth/BearerTokenAuthMechanismTest.java | 8 ++++---- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java index 31f524af3f0..415f3d08b52 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java @@ -24,9 +24,10 @@ public class BearerTokenAuthMechanism implements AuthMechanism { private static final String BEARER_AUTH_SCHEME = "Bearer"; private static final Logger logger = Logger.getLogger(BearerTokenAuthMechanism.class.getCanonicalName()); - public static final String UNAUTHORIZED_BEARER_TOKEN = "Unauthorized bearer token"; - public static final String INVALID_BEARER_TOKEN = "Could not parse bearer token"; - public static final String BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED = "Bearer token detected, no OIDC provider configured"; + public static final String RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN = "Unauthorized bearer token"; + public static final String RESPONSE_MESSAGE_INVALID_BEARER_TOKEN = "Could not parse bearer token"; + public static final String RESPONSE_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED = "Bearer token detected, no OIDC provider configured"; + public static final String RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER = "Bearer token is validated, but there is no linked user account"; @Inject protected AuthenticationServiceBean authSvc; @@ -55,9 +56,7 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) } else { // a valid Token was presented, but we have no associated user account. logger.log(Level.WARNING, "Bearer token detected, OIDC provider {0} validated Token but no linked UserAccount", userInfo.getUserRepoId()); - // TODO: Instead of returning null, we should throw a meaningful error to the client. - // Probably this will be a wrapped auth error response with an error code and a string describing the problem. - return null; + throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER, true); } } return null; @@ -67,7 +66,7 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) * Verifies the given Bearer token and obtain information about the corresponding user within respective AuthProvider. * * @param token The string containing the encoded JWT - * @return + * @return UserRecordIdentifier representing the user. */ private UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String token) throws WrappedAuthErrorResponse { try { @@ -80,7 +79,7 @@ private UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String to // If not OIDC Provider are configured we cannot validate a Token if(providers.isEmpty()){ logger.log(Level.WARNING, "Bearer token detected, no OIDC provider configured"); - throw new WrappedAuthErrorResponse(BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED); + throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED); } // Iterate over all OIDC providers if multiple. Sadly needed as do not know which provided the Token. @@ -101,12 +100,12 @@ private UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String to } } catch (ParseException e) { logger.log(Level.FINE, "Bearer token detected, unable to parse bearer token (invalid Token)", e); - throw new WrappedAuthErrorResponse(INVALID_BEARER_TOKEN); + throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_INVALID_BEARER_TOKEN); } // No UserInfo returned means we have an invalid access token. logger.log(Level.FINE, "Bearer token detected, yet no configured OIDC provider validated it."); - throw new WrappedAuthErrorResponse(UNAUTHORIZED_BEARER_TOKEN); + throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN); } /** diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedAuthErrorResponse.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedAuthErrorResponse.java index 40431557261..d08a95c1b31 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedAuthErrorResponse.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedAuthErrorResponse.java @@ -12,12 +12,25 @@ public class WrappedAuthErrorResponse extends Exception { private final Response response; public WrappedAuthErrorResponse(String message) { + this(message, false); + } + + public WrappedAuthErrorResponse(String message, boolean forbidden) { this.message = message; - this.response = Response.status(Response.Status.UNAUTHORIZED) + this.response = createErrorResponse( + forbidden ? Response.Status.FORBIDDEN : Response.Status.UNAUTHORIZED, + message + ); + } + + private Response createErrorResponse(Response.Status status, String message) { + return Response.status(status) .entity(NullSafeJsonBuilder.jsonObjectBuilder() .add("status", ApiConstants.STATUS_ERROR) .add("message", message).build() - ).type(MediaType.APPLICATION_JSON_TYPE).build(); + ) + .type(MediaType.APPLICATION_JSON_TYPE) + .build(); } public String getMessage() { diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java index 7e1c23d26f4..3aa43ee6774 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java @@ -56,7 +56,7 @@ void testFindUserFromRequest_invalid_token() { WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertEquals(INVALID_BEARER_TOKEN, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_INVALID_BEARER_TOKEN, wrappedAuthErrorResponse.getMessage()); } @Test void testFindUserFromRequest_no_OidcProvider() { @@ -66,7 +66,7 @@ void testFindUserFromRequest_no_OidcProvider() { WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertEquals(BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED, wrappedAuthErrorResponse.getMessage()); } @Test @@ -87,7 +87,7 @@ void testFindUserFromRequest_oneProvider_invalidToken_1() throws ParseException, WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertEquals(UNAUTHORIZED_BEARER_TOKEN, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, wrappedAuthErrorResponse.getMessage()); } @Test @@ -108,7 +108,7 @@ void testFindUserFromRequest_oneProvider_invalidToken_2() throws ParseException, WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertEquals(UNAUTHORIZED_BEARER_TOKEN, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, wrappedAuthErrorResponse.getMessage()); } @Test void testFindUserFromRequest_oneProvider_validToken() throws WrappedAuthErrorResponse, ParseException, IOException { From e42eb5b4fb4ca592877a75377ba8c8755c706442 Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 28 Oct 2024 09:59:43 +0000 Subject: [PATCH 02/62] Changed: update BearerTokenAuthMechanismTest --- .../iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java index 3aa43ee6774..e24b4e59ffc 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java @@ -155,13 +155,11 @@ void testFindUserFromRequest_oneProvider_validToken_noAccount() throws WrappedAu // ensures that the AuthenticationServiceBean can retrieve an Authenticated user based on the UserRecordIdentifier Mockito.when(sut.authSvc.lookupUser(userinfo)).thenReturn(null); - // when ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer " + TEST_API_KEY); - User actual = sut.findUserFromRequest(testContainerRequest); + WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertNull(actual); - + assertEquals(RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER, wrappedAuthErrorResponse.getMessage()); } } From 89b31198091c0b8ded12ca9db052c177e5bde1c8 Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 28 Oct 2024 10:53:23 +0000 Subject: [PATCH 03/62] Changed: using separate classes for wrapped auth error responses --- .../api/auth/ApiKeyAuthMechanism.java | 7 +++--- .../api/auth/BearerTokenAuthMechanism.java | 10 ++++----- .../api/auth/SignedUrlAuthMechanism.java | 2 +- .../api/auth/WorkflowKeyAuthMechanism.java | 2 +- .../api/auth/WrappedAuthErrorResponse.java | 15 ++++--------- .../WrappedForbiddenAuthErrorResponse.java | 10 +++++++++ .../WrappedUnauthorizedAuthErrorResponse.java | 10 +++++++++ .../api/auth/ApiKeyAuthMechanismTest.java | 8 +++---- .../auth/BearerTokenAuthMechanismTest.java | 22 +++++++++---------- .../api/auth/SignedUrlAuthMechanismTest.java | 12 +++++----- .../auth/WorkflowKeyAuthMechanismTest.java | 4 ++-- 11 files changed, 58 insertions(+), 44 deletions(-) create mode 100644 src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedForbiddenAuthErrorResponse.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedUnauthorizedAuthErrorResponse.java diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanism.java index 0dd8a28baca..fbb0b484b58 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanism.java @@ -9,6 +9,7 @@ import jakarta.inject.Inject; import jakarta.ws.rs.container.ContainerRequestContext; + import java.util.logging.Logger; /** @@ -49,7 +50,7 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) authUser = userSvc.updateLastApiUseTime(authUser); return authUser; } - throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_BAD_API_KEY); + throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_BAD_API_KEY); } private String getRequestApiKey(ContainerRequestContext containerRequestContext) { @@ -59,7 +60,7 @@ private String getRequestApiKey(ContainerRequestContext containerRequestContext) return headerParamApiKey != null ? headerParamApiKey : queryParamApiKey; } - private void checkAnonymizedAccessToRequestPath(String requestPath, PrivateUrlUser privateUrlUser) throws WrappedAuthErrorResponse { + private void checkAnonymizedAccessToRequestPath(String requestPath, PrivateUrlUser privateUrlUser) throws WrappedUnauthorizedAuthErrorResponse { if (!privateUrlUser.hasAnonymizedAccess()) { return; } @@ -67,7 +68,7 @@ private void checkAnonymizedAccessToRequestPath(String requestPath, PrivateUrlUs // to download the file or image thumbs if (!(requestPath.startsWith(ACCESS_DATAFILE_PATH_PREFIX) && !requestPath.substring(ACCESS_DATAFILE_PATH_PREFIX.length()).contains("/"))) { logger.info("Anonymized access request for " + requestPath); - throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_BAD_API_KEY); + throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_BAD_API_KEY); } } } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java index 415f3d08b52..1df265cbc9f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java @@ -56,7 +56,7 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) } else { // a valid Token was presented, but we have no associated user account. logger.log(Level.WARNING, "Bearer token detected, OIDC provider {0} validated Token but no linked UserAccount", userInfo.getUserRepoId()); - throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER, true); + throw new WrappedForbiddenAuthErrorResponse(RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER); } } return null; @@ -68,7 +68,7 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) * @param token The string containing the encoded JWT * @return UserRecordIdentifier representing the user. */ - private UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String token) throws WrappedAuthErrorResponse { + private UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String token) throws WrappedUnauthorizedAuthErrorResponse { try { BearerAccessToken accessToken = BearerAccessToken.parse(token); // Get list of all authentication providers using Open ID Connect @@ -79,7 +79,7 @@ private UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String to // If not OIDC Provider are configured we cannot validate a Token if(providers.isEmpty()){ logger.log(Level.WARNING, "Bearer token detected, no OIDC provider configured"); - throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED); + throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED); } // Iterate over all OIDC providers if multiple. Sadly needed as do not know which provided the Token. @@ -100,12 +100,12 @@ private UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String to } } catch (ParseException e) { logger.log(Level.FINE, "Bearer token detected, unable to parse bearer token (invalid Token)", e); - throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_INVALID_BEARER_TOKEN); + throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_INVALID_BEARER_TOKEN); } // No UserInfo returned means we have an invalid access token. logger.log(Level.FINE, "Bearer token detected, yet no configured OIDC provider validated it."); - throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN); + throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN); } /** diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanism.java index 258661f6495..30e8a3b9ca4 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanism.java @@ -43,7 +43,7 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) if (user != null) { return user; } - throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_BAD_SIGNED_URL); + throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_BAD_SIGNED_URL); } private String getSignedUrlRequestParameter(ContainerRequestContext containerRequestContext) { diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanism.java index bbd67713e85..df54b69af96 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanism.java @@ -30,7 +30,7 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) if (authUser != null) { return authUser; } - throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_BAD_WORKFLOW_KEY); + throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_BAD_WORKFLOW_KEY); } private String getRequestWorkflowKey(ContainerRequestContext containerRequestContext) { diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedAuthErrorResponse.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedAuthErrorResponse.java index d08a95c1b31..da92d882197 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedAuthErrorResponse.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedAuthErrorResponse.java @@ -6,24 +6,17 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -public class WrappedAuthErrorResponse extends Exception { +public abstract class WrappedAuthErrorResponse extends Exception { private final String message; private final Response response; - public WrappedAuthErrorResponse(String message) { - this(message, false); - } - - public WrappedAuthErrorResponse(String message, boolean forbidden) { + public WrappedAuthErrorResponse(Response.Status status, String message) { this.message = message; - this.response = createErrorResponse( - forbidden ? Response.Status.FORBIDDEN : Response.Status.UNAUTHORIZED, - message - ); + this.response = createErrorResponse(status, message); } - private Response createErrorResponse(Response.Status status, String message) { + protected Response createErrorResponse(Response.Status status, String message) { return Response.status(status) .entity(NullSafeJsonBuilder.jsonObjectBuilder() .add("status", ApiConstants.STATUS_ERROR) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedForbiddenAuthErrorResponse.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedForbiddenAuthErrorResponse.java new file mode 100644 index 00000000000..082ed3ca8d8 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedForbiddenAuthErrorResponse.java @@ -0,0 +1,10 @@ +package edu.harvard.iq.dataverse.api.auth; + +import jakarta.ws.rs.core.Response; + +public class WrappedForbiddenAuthErrorResponse extends WrappedAuthErrorResponse { + + public WrappedForbiddenAuthErrorResponse(String message) { + super(Response.Status.FORBIDDEN, message); + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedUnauthorizedAuthErrorResponse.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedUnauthorizedAuthErrorResponse.java new file mode 100644 index 00000000000..1d2eb8f8bd8 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedUnauthorizedAuthErrorResponse.java @@ -0,0 +1,10 @@ +package edu.harvard.iq.dataverse.api.auth; + +import jakarta.ws.rs.core.Response; + +public class WrappedUnauthorizedAuthErrorResponse extends WrappedAuthErrorResponse { + + public WrappedUnauthorizedAuthErrorResponse(String message) { + super(Response.Status.UNAUTHORIZED, message); + } +} diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanismTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanismTest.java index 486697664e6..12216819cf8 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanismTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanismTest.java @@ -84,9 +84,9 @@ public void testFindUserFromRequest_ApiKeyProvided_AnonymizedPrivateUrlUserAuthe sut.userSvc = Mockito.mock(UserServiceBean.class); ContainerRequestContext testContainerRequest = new ApiKeyContainerRequestTestFake(TEST_API_KEY, TEST_PATH); - WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); + WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); - assertEquals(RESPONSE_MESSAGE_BAD_API_KEY, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_BAD_API_KEY, wrappedUnauthorizedAuthErrorResponse.getMessage()); } @Test @@ -123,8 +123,8 @@ public void testFindUserFromRequest_ApiKeyProvided_CanNotAuthenticateUserWithAny sut.userSvc = Mockito.mock(UserServiceBean.class); ContainerRequestContext testContainerRequest = new ApiKeyContainerRequestTestFake(TEST_API_KEY, TEST_PATH); - WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); + WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); - assertEquals(RESPONSE_MESSAGE_BAD_API_KEY, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_BAD_API_KEY, wrappedUnauthorizedAuthErrorResponse.getMessage()); } } diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java index e24b4e59ffc..19828fc494c 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java @@ -53,20 +53,20 @@ void testFindUserFromRequest_invalid_token() { Mockito.when(sut.authSvc.getAuthenticationProviderIdsOfType(OIDCAuthProvider.class)).thenReturn(Collections.emptySet()); ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer "); - WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); + WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertEquals(RESPONSE_MESSAGE_INVALID_BEARER_TOKEN, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_INVALID_BEARER_TOKEN, wrappedUnauthorizedAuthErrorResponse.getMessage()); } @Test void testFindUserFromRequest_no_OidcProvider() { Mockito.when(sut.authSvc.getAuthenticationProviderIdsOfType(OIDCAuthProvider.class)).thenReturn(Collections.emptySet()); ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer " +TEST_API_KEY); - WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); + WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertEquals(RESPONSE_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED, wrappedUnauthorizedAuthErrorResponse.getMessage()); } @Test @@ -84,10 +84,10 @@ void testFindUserFromRequest_oneProvider_invalidToken_1() throws ParseException, // when ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer " + TEST_API_KEY); - WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); + WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertEquals(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, wrappedUnauthorizedAuthErrorResponse.getMessage()); } @Test @@ -105,10 +105,10 @@ void testFindUserFromRequest_oneProvider_invalidToken_2() throws ParseException, // when ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer " + TEST_API_KEY); - WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); + WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertEquals(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, wrappedUnauthorizedAuthErrorResponse.getMessage()); } @Test void testFindUserFromRequest_oneProvider_validToken() throws WrappedAuthErrorResponse, ParseException, IOException { @@ -139,7 +139,7 @@ void testFindUserFromRequest_oneProvider_validToken() throws WrappedAuthErrorRes } @Test - void testFindUserFromRequest_oneProvider_validToken_noAccount() throws WrappedAuthErrorResponse, ParseException, IOException { + void testFindUserFromRequest_oneProvider_validToken_noAccount() throws ParseException, IOException { OIDCAuthProvider oidcAuthProvider = Mockito.mock(OIDCAuthProvider.class); String providerID = "OIEDC"; Mockito.when(oidcAuthProvider.getId()).thenReturn(providerID); @@ -157,9 +157,9 @@ void testFindUserFromRequest_oneProvider_validToken_noAccount() throws WrappedAu // when ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer " + TEST_API_KEY); - WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); + WrappedForbiddenAuthErrorResponse wrappedForbiddenAuthErrorResponse = assertThrows(WrappedForbiddenAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertEquals(RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER, wrappedForbiddenAuthErrorResponse.getMessage()); } } diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanismTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanismTest.java index 74db6e544da..6fd7d2e1d8e 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanismTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanismTest.java @@ -65,9 +65,9 @@ public void testFindUserFromRequest_SignedUrlTokenProvided_UserExists_InvalidSig sut.authSvc = authenticationServiceBeanStub; ContainerRequestContext testContainerRequest = new SignedUrlContainerRequestTestFake(TEST_SIGNED_URL_TOKEN, TEST_SIGNED_URL_USER_ID); - WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); + WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); - assertEquals(RESPONSE_MESSAGE_BAD_SIGNED_URL, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_BAD_SIGNED_URL, wrappedUnauthorizedAuthErrorResponse.getMessage()); } @Test @@ -79,9 +79,9 @@ public void testFindUserFromRequest_SignedUrlTokenProvided_UserExists_UserApiTok sut.authSvc = authenticationServiceBeanStub; ContainerRequestContext testContainerRequest = new SignedUrlContainerRequestTestFake(TEST_SIGNED_URL_TOKEN, TEST_SIGNED_URL_USER_ID); - WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); + WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); - assertEquals(RESPONSE_MESSAGE_BAD_SIGNED_URL, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_BAD_SIGNED_URL, wrappedUnauthorizedAuthErrorResponse.getMessage()); } @Test @@ -92,8 +92,8 @@ public void testFindUserFromRequest_SignedUrlTokenProvided_UserDoesNotExistForTh sut.authSvc = authenticationServiceBeanStub; ContainerRequestContext testContainerRequest = new SignedUrlContainerRequestTestFake(TEST_SIGNED_URL_TOKEN, TEST_SIGNED_URL_USER_ID); - WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); + WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); - assertEquals(RESPONSE_MESSAGE_BAD_SIGNED_URL, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_BAD_SIGNED_URL, wrappedUnauthorizedAuthErrorResponse.getMessage()); } } diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanismTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanismTest.java index 3f90fa73fa9..22c3abffe2b 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanismTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanismTest.java @@ -54,8 +54,8 @@ public void testFindUserFromRequest_WorkflowKeyProvided_UserNotAuthenticated() { sut.authSvc = authenticationServiceBeanStub; ContainerRequestContext testContainerRequest = new WorkflowKeyContainerRequestTestFake(TEST_WORKFLOW_KEY); - WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); + WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); - assertEquals(RESPONSE_MESSAGE_BAD_WORKFLOW_KEY, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_BAD_WORKFLOW_KEY, wrappedUnauthorizedAuthErrorResponse.getMessage()); } } From 300e0415f748fbf2000ae6baf1eaf848cb7c4f1c Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 30 Oct 2024 11:14:17 +0000 Subject: [PATCH 04/62] Refactor: extracted OIDC user lookup and token verify from BearerTokenAuthMechanism to AuthenticationServiceBean --- .../api/auth/BearerTokenAuthMechanism.java | 110 +++++------------- .../AuthenticationServiceBean.java | 93 +++++++++++++-- .../auth/BearerTokenAuthMechanismTest.java | 110 +++--------------- 3 files changed, 123 insertions(+), 190 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java index 1df265cbc9f..0dd2b9e0f9f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java @@ -1,11 +1,8 @@ package edu.harvard.iq.dataverse.api.auth; -import com.nimbusds.oauth2.sdk.ParseException; -import com.nimbusds.oauth2.sdk.token.BearerAccessToken; import edu.harvard.iq.dataverse.UserServiceBean; import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; -import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; -import edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthProvider; +import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.settings.FeatureFlags; @@ -13,111 +10,60 @@ import jakarta.inject.Inject; import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.core.HttpHeaders; -import java.io.IOException; -import java.util.List; + import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; -import java.util.stream.Collectors; public class BearerTokenAuthMechanism implements AuthMechanism { private static final String BEARER_AUTH_SCHEME = "Bearer"; private static final Logger logger = Logger.getLogger(BearerTokenAuthMechanism.class.getCanonicalName()); - - public static final String RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN = "Unauthorized bearer token"; - public static final String RESPONSE_MESSAGE_INVALID_BEARER_TOKEN = "Could not parse bearer token"; - public static final String RESPONSE_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED = "Bearer token detected, no OIDC provider configured"; + public static final String RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER = "Bearer token is validated, but there is no linked user account"; @Inject protected AuthenticationServiceBean authSvc; @Inject protected UserServiceBean userSvc; - + @Override public User findUserFromRequest(ContainerRequestContext containerRequestContext) throws WrappedAuthErrorResponse { - if (FeatureFlags.API_BEARER_AUTH.enabled()) { - Optional bearerToken = getRequestApiKey(containerRequestContext); - // No Bearer Token present, hence no user can be authenticated - if (bearerToken.isEmpty()) { - return null; - } - - // Validate and verify provided Bearer Token, and retrieve UserRecordIdentifier - // TODO: Get the identifier from an invalidating cache to avoid lookup bursts of the same token. Tokens in the cache should be removed after some (configurable) time. - UserRecordIdentifier userInfo = verifyOidcBearerTokenAndGetUserIdentifier(bearerToken.get()); + if (!FeatureFlags.API_BEARER_AUTH.enabled()) { + return null; + } - // retrieve Authenticated User from AuthService - AuthenticatedUser authUser = authSvc.lookupUser(userInfo); - if (authUser != null) { - // track the API usage - authUser = userSvc.updateLastApiUseTime(authUser); - return authUser; - } else { - // a valid Token was presented, but we have no associated user account. - logger.log(Level.WARNING, "Bearer token detected, OIDC provider {0} validated Token but no linked UserAccount", userInfo.getUserRepoId()); - throw new WrappedForbiddenAuthErrorResponse(RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER); - } + Optional bearerToken = getRequestBearerToken(containerRequestContext); + if (bearerToken.isEmpty()) { + return null; } - return null; - } - /** - * Verifies the given Bearer token and obtain information about the corresponding user within respective AuthProvider. - * - * @param token The string containing the encoded JWT - * @return UserRecordIdentifier representing the user. - */ - private UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String token) throws WrappedUnauthorizedAuthErrorResponse { + AuthenticatedUser authUser; try { - BearerAccessToken accessToken = BearerAccessToken.parse(token); - // Get list of all authentication providers using Open ID Connect - // @TASK: Limited to OIDCAuthProviders, could be widened to OAuth2Providers. - List providers = authSvc.getAuthenticationProviderIdsOfType(OIDCAuthProvider.class).stream() - .map(providerId -> (OIDCAuthProvider) authSvc.getAuthenticationProvider(providerId)) - .collect(Collectors.toUnmodifiableList()); - // If not OIDC Provider are configured we cannot validate a Token - if(providers.isEmpty()){ - logger.log(Level.WARNING, "Bearer token detected, no OIDC provider configured"); - throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED); - } + authUser = authSvc.lookupUserByOidcBearerToken(bearerToken.get()); + } catch (AuthorizationException e) { + logger.log(Level.WARNING, "Authorization failed: {0}", e.getMessage()); + throw new WrappedUnauthorizedAuthErrorResponse(e.getMessage()); + } - // Iterate over all OIDC providers if multiple. Sadly needed as do not know which provided the Token. - for (OIDCAuthProvider provider : providers) { - try { - // The OIDCAuthProvider need to verify a Bearer Token and equip the client means to identify the corresponding AuthenticatedUser. - Optional userInfo = provider.getUserIdentifier(accessToken); - if(userInfo.isPresent()) { - logger.log(Level.FINE, "Bearer token detected, provider {0} confirmed validity and provided identifier", provider.getId()); - return userInfo.get(); - } - } catch (IOException e) { - // TODO: Just logging this is not sufficient - if there is an IO error with the one provider - // which would have validated successfully, this is not the users fault. We need to - // take note and refer to that later when occurred. - logger.log(Level.FINE, "Bearer token detected, provider " + provider.getId() + " indicates an invalid Token, skipping", e); - } - } - } catch (ParseException e) { - logger.log(Level.FINE, "Bearer token detected, unable to parse bearer token (invalid Token)", e); - throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_INVALID_BEARER_TOKEN); + if (authUser == null) { + logger.log(Level.WARNING, + "Bearer token detected, OIDC provider validated the token but no linked UserAccount"); + throw new WrappedForbiddenAuthErrorResponse(RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER); } - // No UserInfo returned means we have an invalid access token. - logger.log(Level.FINE, "Bearer token detected, yet no configured OIDC provider validated it."); - throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN); + return userSvc.updateLastApiUseTime(authUser); } /** * Retrieve the raw, encoded token value from the Authorization Bearer HTTP header as defined in RFC 6750 + * * @return An {@link Optional} either empty if not present or the raw token from the header */ - private Optional getRequestApiKey(ContainerRequestContext containerRequestContext) { - String headerParamApiKey = containerRequestContext.getHeaderString(HttpHeaders.AUTHORIZATION); - if (headerParamApiKey != null && headerParamApiKey.toLowerCase().startsWith(BEARER_AUTH_SCHEME.toLowerCase() + " ")) { - return Optional.of(headerParamApiKey); - } else { - return Optional.empty(); + private Optional getRequestBearerToken(ContainerRequestContext containerRequestContext) { + String headerParamBearerToken = containerRequestContext.getHeaderString(HttpHeaders.AUTHORIZATION); + if (headerParamBearerToken != null && headerParamBearerToken.toLowerCase().startsWith(BEARER_AUTH_SCHEME.toLowerCase() + " ")) { + return Optional.of(headerParamBearerToken); } + return Optional.empty(); } -} \ No newline at end of file +} diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java index 4a8fb123fd4..c9c3db43746 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -1,11 +1,15 @@ package edu.harvard.iq.dataverse.authorization; +import com.nimbusds.oauth2.sdk.ParseException; +import com.nimbusds.oauth2.sdk.token.BearerAccessToken; import edu.harvard.iq.dataverse.DatasetVersionServiceBean; import edu.harvard.iq.dataverse.DvObjectServiceBean; import edu.harvard.iq.dataverse.GuestbookResponseServiceBean; import edu.harvard.iq.dataverse.RoleAssigneeServiceBean; import edu.harvard.iq.dataverse.UserNotificationServiceBean; import edu.harvard.iq.dataverse.UserServiceBean; +import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; +import edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthProvider; import edu.harvard.iq.dataverse.search.IndexServiceBean; import edu.harvard.iq.dataverse.actionlogging.ActionLogRecord; import edu.harvard.iq.dataverse.actionlogging.ActionLogServiceBean; @@ -34,17 +38,10 @@ import edu.harvard.iq.dataverse.validation.PasswordValidatorServiceBean; import edu.harvard.iq.dataverse.workflow.PendingWorkflowInvocation; import edu.harvard.iq.dataverse.workflows.WorkflowComment; + +import java.io.IOException; import java.sql.Timestamp; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Calendar; -import java.util.Collection; -import java.util.Date; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeSet; +import java.util.*; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; @@ -127,8 +124,12 @@ public class AuthenticationServiceBean { @PersistenceContext(unitName = "VDCNet-ejbPU") private EntityManager em; - - + + public static final String ERROR_MESSAGE_UNAUTHORIZED_BEARER_TOKEN = "Unauthorized bearer token"; + public static final String ERROR_MESSAGE_INVALID_BEARER_TOKEN = "Could not parse bearer token"; + public static final String ERROR_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED = "Bearer token detected, no OIDC provider configured"; + + public AbstractOAuth2AuthenticationProvider getOAuth2Provider( String id ) { return authProvidersRegistrationService.getOAuth2AuthProvidersMap().get(id); } @@ -978,4 +979,72 @@ public ApiToken getValidApiTokenForUser(User user) { } return apiToken; } + + /** + * Looks up an authenticated user based on the provided OIDC bearer token. + * + * @param bearerToken The OIDC bearer token. + * @return An instance of {@link AuthenticatedUser} representing the authenticated user. + * @throws AuthorizationException If the token is invalid or no OIDC provider is configured. + */ + public AuthenticatedUser lookupUserByOidcBearerToken(String bearerToken) throws AuthorizationException { + // TODO: Get the identifier from an invalidating cache to avoid lookup bursts of the same token. + // Tokens in the cache should be removed after some (configurable) time. + UserRecordIdentifier userInfo = verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); + return lookupUser(userInfo); + } + + /** + * Verifies the given OIDC bearer token and retrieves the corresponding user's identifier. + * + * @param bearerToken The OIDC bearer token. + * @return A {@link UserRecordIdentifier} representing the user associated with the valid token. + * @throws AuthorizationException If the token is invalid or if no OIDC providers are available. + */ + private UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String bearerToken) throws AuthorizationException { + try { + BearerAccessToken accessToken = BearerAccessToken.parse(bearerToken); + List providers = getAvailableOidcProviders(); + + // Ensure at least one OIDC provider is configured to validate the token. + if (providers.isEmpty()) { + logger.log(Level.WARNING, "Bearer token detected, no OIDC provider configured"); + throw new AuthorizationException(ERROR_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED); + } + + // Attempt to validate the token with each configured OIDC provider. + for (OIDCAuthProvider provider : providers) { + try { + Optional userInfo = provider.getUserIdentifier(accessToken); + if (userInfo.isPresent()) { + logger.log(Level.FINE, "Bearer token detected, provider {0} confirmed validity and provided identifier", provider.getId()); + return userInfo.get(); + } + } catch (IOException e) { + // TODO: Just logging this is not sufficient - if there is an IO error with the one provider + // which would have validated successfully, this is not the users fault. We need to + // take note and refer to that later when occurred. + logger.log(Level.FINE, "Bearer token detected, provider " + provider.getId() + " indicates an invalid Token, skipping", e); + } + } + } catch (ParseException e) { + logger.log(Level.FINE, "Bearer token detected, unable to parse bearer token (invalid Token)", e); + throw new AuthorizationException(ERROR_MESSAGE_INVALID_BEARER_TOKEN); + } + + // If no provider validated the token, throw an authorization exception. + logger.log(Level.FINE, "Bearer token detected, yet no configured OIDC provider validated it."); + throw new AuthorizationException(ERROR_MESSAGE_UNAUTHORIZED_BEARER_TOKEN); + } + + /** + * Retrieves a list of configured OIDC authentication providers. + * + * @return A list of available OIDCAuthProviders. + */ + private List getAvailableOidcProviders() { + return getAuthenticationProviderIdsOfType(OIDCAuthProvider.class).stream() + .map(providerId -> (OIDCAuthProvider) getAuthenticationProvider(providerId)) + .toList(); + } } diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java index 19828fc494c..c8a1ef8f087 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java @@ -1,12 +1,9 @@ package edu.harvard.iq.dataverse.api.auth; -import com.nimbusds.oauth2.sdk.ParseException; -import com.nimbusds.oauth2.sdk.token.BearerAccessToken; import edu.harvard.iq.dataverse.UserServiceBean; import edu.harvard.iq.dataverse.api.auth.doubles.BearerTokenKeyContainerRequestTestFake; import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; -import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; -import edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthProvider; +import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.settings.JvmSettings; @@ -18,10 +15,6 @@ import jakarta.ws.rs.container.ContainerRequestContext; -import java.io.IOException; -import java.util.Collections; -import java.util.Optional; - import static edu.harvard.iq.dataverse.api.auth.BearerTokenAuthMechanism.*; import static org.junit.jupiter.api.Assertions.*; @@ -29,7 +22,7 @@ @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth") class BearerTokenAuthMechanismTest { - private static final String TEST_API_KEY = "test-api-key"; + private static final String TEST_BEARER_TOKEN = "Bearer test"; private BearerTokenAuthMechanism sut; @@ -49,114 +42,39 @@ void testFindUserFromRequest_no_token() throws WrappedAuthErrorResponse { } @Test - void testFindUserFromRequest_invalid_token() { - Mockito.when(sut.authSvc.getAuthenticationProviderIdsOfType(OIDCAuthProvider.class)).thenReturn(Collections.emptySet()); - - ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer "); - WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); - - //then - assertEquals(RESPONSE_MESSAGE_INVALID_BEARER_TOKEN, wrappedUnauthorizedAuthErrorResponse.getMessage()); - } - @Test - void testFindUserFromRequest_no_OidcProvider() { - Mockito.when(sut.authSvc.getAuthenticationProviderIdsOfType(OIDCAuthProvider.class)).thenReturn(Collections.emptySet()); - - ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer " +TEST_API_KEY); - WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); - - //then - assertEquals(RESPONSE_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED, wrappedUnauthorizedAuthErrorResponse.getMessage()); - } - - @Test - void testFindUserFromRequest_oneProvider_invalidToken_1() throws ParseException, IOException { - OIDCAuthProvider oidcAuthProvider = Mockito.mock(OIDCAuthProvider.class); - String providerID = "OIEDC"; - Mockito.when(oidcAuthProvider.getId()).thenReturn(providerID); - // ensure that a valid OIDCAuthProvider is available within the AuthenticationServiceBean - Mockito.when(sut.authSvc.getAuthenticationProviderIdsOfType(OIDCAuthProvider.class)).thenReturn(Collections.singleton(providerID)); - Mockito.when(sut.authSvc.getAuthenticationProvider(providerID)).thenReturn(oidcAuthProvider); - - // ensure that the OIDCAuthProvider returns a valid UserRecordIdentifier for a given Token - BearerAccessToken token = BearerAccessToken.parse("Bearer " + TEST_API_KEY); - Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.empty()); + void testFindUserFromRequest_invalid_token() throws AuthorizationException { + String testErrorMessage = "test error"; + Mockito.when(sut.authSvc.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)).thenThrow(new AuthorizationException(testErrorMessage)); // when - ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer " + TEST_API_KEY); + ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake(TEST_BEARER_TOKEN); WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertEquals(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, wrappedUnauthorizedAuthErrorResponse.getMessage()); + assertEquals(testErrorMessage, wrappedUnauthorizedAuthErrorResponse.getMessage()); } @Test - void testFindUserFromRequest_oneProvider_invalidToken_2() throws ParseException, IOException { - OIDCAuthProvider oidcAuthProvider = Mockito.mock(OIDCAuthProvider.class); - String providerID = "OIEDC"; - Mockito.when(oidcAuthProvider.getId()).thenReturn(providerID); - // ensure that a valid OIDCAuthProvider is available within the AuthenticationServiceBean - Mockito.when(sut.authSvc.getAuthenticationProviderIdsOfType(OIDCAuthProvider.class)).thenReturn(Collections.singleton(providerID)); - Mockito.when(sut.authSvc.getAuthenticationProvider(providerID)).thenReturn(oidcAuthProvider); - - // ensure that the OIDCAuthProvider returns a valid UserRecordIdentifier for a given Token - BearerAccessToken token = BearerAccessToken.parse("Bearer " + TEST_API_KEY); - Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenThrow(IOException.class); - - // when - ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer " + TEST_API_KEY); - WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); - - //then - assertEquals(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, wrappedUnauthorizedAuthErrorResponse.getMessage()); - } - @Test - void testFindUserFromRequest_oneProvider_validToken() throws WrappedAuthErrorResponse, ParseException, IOException { - OIDCAuthProvider oidcAuthProvider = Mockito.mock(OIDCAuthProvider.class); - String providerID = "OIEDC"; - Mockito.when(oidcAuthProvider.getId()).thenReturn(providerID); - // ensure that a valid OIDCAuthProvider is available within the AuthenticationServiceBean - Mockito.when(sut.authSvc.getAuthenticationProviderIdsOfType(OIDCAuthProvider.class)).thenReturn(Collections.singleton(providerID)); - Mockito.when(sut.authSvc.getAuthenticationProvider(providerID)).thenReturn(oidcAuthProvider); - - // ensure that the OIDCAuthProvider returns a valid UserRecordIdentifier for a given Token - UserRecordIdentifier userinfo = new UserRecordIdentifier(providerID, "KEY"); - BearerAccessToken token = BearerAccessToken.parse("Bearer " + TEST_API_KEY); - Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.of(userinfo)); - - // ensures that the AuthenticationServiceBean can retrieve an Authenticated user based on the UserRecordIdentifier + void testFindUserFromRequest_validToken_accountExists() throws WrappedAuthErrorResponse, AuthorizationException { AuthenticatedUser testAuthenticatedUser = new AuthenticatedUser(); - Mockito.when(sut.authSvc.lookupUser(userinfo)).thenReturn(testAuthenticatedUser); + Mockito.when(sut.authSvc.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)).thenReturn(testAuthenticatedUser); Mockito.when(sut.userSvc.updateLastApiUseTime(testAuthenticatedUser)).thenReturn(testAuthenticatedUser); // when - ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer " + TEST_API_KEY); + ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake(TEST_BEARER_TOKEN); User actual = sut.findUserFromRequest(testContainerRequest); //then assertEquals(testAuthenticatedUser, actual); Mockito.verify(sut.userSvc, Mockito.atLeastOnce()).updateLastApiUseTime(testAuthenticatedUser); - } + @Test - void testFindUserFromRequest_oneProvider_validToken_noAccount() throws ParseException, IOException { - OIDCAuthProvider oidcAuthProvider = Mockito.mock(OIDCAuthProvider.class); - String providerID = "OIEDC"; - Mockito.when(oidcAuthProvider.getId()).thenReturn(providerID); - // ensure that a valid OIDCAuthProvider is available within the AuthenticationServiceBean - Mockito.when(sut.authSvc.getAuthenticationProviderIdsOfType(OIDCAuthProvider.class)).thenReturn(Collections.singleton(providerID)); - Mockito.when(sut.authSvc.getAuthenticationProvider(providerID)).thenReturn(oidcAuthProvider); - - // ensure that the OIDCAuthProvider returns a valid UserRecordIdentifier for a given Token - UserRecordIdentifier userinfo = new UserRecordIdentifier(providerID, "KEY"); - BearerAccessToken token = BearerAccessToken.parse("Bearer " + TEST_API_KEY); - Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.of(userinfo)); - - // ensures that the AuthenticationServiceBean can retrieve an Authenticated user based on the UserRecordIdentifier - Mockito.when(sut.authSvc.lookupUser(userinfo)).thenReturn(null); + void testFindUserFromRequest_validToken_noAccount() throws AuthorizationException { + Mockito.when(sut.authSvc.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)).thenReturn(null); // when - ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer " + TEST_API_KEY); + ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake(TEST_BEARER_TOKEN); WrappedForbiddenAuthErrorResponse wrappedForbiddenAuthErrorResponse = assertThrows(WrappedForbiddenAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then From ba70a04da1947377bafece67679771baea89b091 Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 30 Oct 2024 13:06:15 +0000 Subject: [PATCH 05/62] Added: unit tests to newly added methods in AuthenticationServiceBean --- .../AuthenticationServiceBean.java | 2 +- .../AuthenticationServiceBeanTest.java | 131 ++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java index c9c3db43746..14caea5399b 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -123,7 +123,7 @@ public class AuthenticationServiceBean { PrivateUrlServiceBean privateUrlService; @PersistenceContext(unitName = "VDCNet-ejbPU") - private EntityManager em; + EntityManager em; public static final String ERROR_MESSAGE_UNAUTHORIZED_BEARER_TOKEN = "Unauthorized bearer token"; public static final String ERROR_MESSAGE_INVALID_BEARER_TOKEN = "Could not parse bearer token"; diff --git a/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java b/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java new file mode 100644 index 00000000000..be98bcb516d --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java @@ -0,0 +1,131 @@ +package edu.harvard.iq.dataverse.authorization; + +import com.nimbusds.oauth2.sdk.ParseException; +import com.nimbusds.oauth2.sdk.token.BearerAccessToken; +import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; +import edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthProvider; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.authorization.users.User; +import jakarta.persistence.EntityManager; +import jakarta.persistence.NoResultException; +import jakarta.persistence.TypedQuery; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.io.IOException; +import java.util.Map; +import java.util.Optional; + +import static edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean.*; +import static org.junit.jupiter.api.Assertions.*; + +public class AuthenticationServiceBeanTest { + + private AuthenticationServiceBean sut; + private static final String TEST_BEARER_TOKEN = "Bearer test"; + + @BeforeEach + public void setUp() { + sut = new AuthenticationServiceBean(); + sut.authProvidersRegistrationService = Mockito.mock(AuthenticationProvidersRegistrationServiceBean.class); + sut.em = Mockito.mock(EntityManager.class); + } + + @Test + void testLookupUserByOidcBearerToken_no_OidcProvider() { + // Given no OIDC providers are configured + Mockito.when(sut.authProvidersRegistrationService.getAuthenticationProvidersMap()).thenReturn(Map.of()); + + // When invoking lookupUserByOidcBearerToken + AuthorizationException exception = assertThrows(AuthorizationException.class, + () -> sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)); + + // Then the exception message should indicate no OIDC provider is configured + assertEquals(ERROR_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED, exception.getMessage()); + } + + @Test + void testLookupUserByOidcBearerToken_oneProvider_invalidToken_1() throws ParseException, IOException { + // Given a single OIDC provider that cannot find a user + OIDCAuthProvider oidcAuthProvider = mockOidcAuthProvider("OIEDC"); + BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); + Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.empty()); + + // When invoking lookupUserByOidcBearerToken + AuthorizationException exception = assertThrows(AuthorizationException.class, + () -> sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)); + + // Then the exception message should indicate an unauthorized token + assertEquals(ERROR_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, exception.getMessage()); + } + + @Test + void testLookupUserByOidcBearerToken_oneProvider_invalidToken_2() throws ParseException, IOException { + // Given a single OIDC provider that throws an IOException + OIDCAuthProvider oidcAuthProvider = mockOidcAuthProvider("OIEDC"); + BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); + Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenThrow(IOException.class); + + // When invoking lookupUserByOidcBearerToken + AuthorizationException exception = assertThrows(AuthorizationException.class, + () -> sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)); + + // Then the exception message should indicate an unauthorized token + assertEquals(ERROR_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, exception.getMessage()); + } + + @Test + void testLookupUserByOidcBearerToken_oneProvider_validToken() throws ParseException, IOException, AuthorizationException { + // Given a single OIDC provider that returns a valid user identifier + OIDCAuthProvider oidcAuthProvider = mockOidcAuthProvider("OIEDC"); + AuthenticatedUser authenticatedUser = setupAuthenticatedUserQueryWithResult(new AuthenticatedUser()); + UserRecordIdentifier userInfo = new UserRecordIdentifier("OIEDC", "KEY"); + BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); + Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.of(userInfo)); + + // When invoking lookupUserByOidcBearerToken + User actualUser = sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN); + + // Then the actual user should match the expected authenticated user + assertEquals(authenticatedUser, actualUser); + } + + @Test + void testLookupUserByOidcBearerToken_oneProvider_validToken_noAccount() throws ParseException, IOException, AuthorizationException { + // Given a single OIDC provider with a valid user identifier but no account exists + OIDCAuthProvider oidcAuthProvider = mockOidcAuthProvider("OIEDC"); + setupAuthenticatedUserQueryWithNoResult(); + UserRecordIdentifier userInfo = new UserRecordIdentifier("OIEDC", "KEY"); + BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); + Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.of(userInfo)); + + // When invoking lookupUserByOidcBearerToken + User actualUser = sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN); + + // Then no user should be found, and result should be null + assertNull(actualUser); + } + + private OIDCAuthProvider mockOidcAuthProvider(String providerID) { + OIDCAuthProvider oidcAuthProvider = Mockito.mock(OIDCAuthProvider.class); + Mockito.when(oidcAuthProvider.getId()).thenReturn(providerID); + Mockito.when(sut.authProvidersRegistrationService.getAuthenticationProvidersMap()).thenReturn(Map.of(providerID, oidcAuthProvider)); + return oidcAuthProvider; + } + + private AuthenticatedUser setupAuthenticatedUserQueryWithResult(AuthenticatedUser authenticatedUser) { + TypedQuery queryMock = Mockito.mock(TypedQuery.class); + AuthenticatedUserLookup lookupMock = Mockito.mock(AuthenticatedUserLookup.class); + Mockito.when(lookupMock.getAuthenticatedUser()).thenReturn(authenticatedUser); + Mockito.when(queryMock.getSingleResult()).thenReturn(lookupMock); + Mockito.when(sut.em.createNamedQuery("AuthenticatedUserLookup.findByAuthPrvID_PersUserId", AuthenticatedUserLookup.class)).thenReturn(queryMock); + return authenticatedUser; + } + + private void setupAuthenticatedUserQueryWithNoResult() { + TypedQuery queryMock = Mockito.mock(TypedQuery.class); + Mockito.when(queryMock.getSingleResult()).thenThrow(new NoResultException()); + Mockito.when(sut.em.createNamedQuery("AuthenticatedUserLookup.findByAuthPrvID_PersUserId", AuthenticatedUserLookup.class)).thenReturn(queryMock); + } +} From 80ad5a4d5868a09c620eeb8f3d8a7b81bdb16bf1 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 1 Nov 2024 19:26:16 +0000 Subject: [PATCH 06/62] Stash: implementing users/register endpoint WIP --- .../edu/harvard/iq/dataverse/api/Users.java | 42 +++++++++++++++---- .../harvard/iq/dataverse/api/dto/UserDTO.java | 13 ++++++ .../command/impl/RegisterOidcUserCommand.java | 35 ++++++++++++++++ .../iq/dataverse/util/json/JsonParser.java | 5 +++ 4 files changed, 86 insertions(+), 9 deletions(-) create mode 100644 src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Users.java b/src/main/java/edu/harvard/iq/dataverse/api/Users.java index c1a7c95dbff..ef65363cd0d 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Users.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Users.java @@ -9,28 +9,25 @@ import edu.harvard.iq.dataverse.authorization.users.ApiToken; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.User; -import edu.harvard.iq.dataverse.engine.command.impl.ChangeUserIdentifierCommand; -import edu.harvard.iq.dataverse.engine.command.impl.GetUserTracesCommand; -import edu.harvard.iq.dataverse.engine.command.impl.MergeInAccountCommand; -import edu.harvard.iq.dataverse.engine.command.impl.RevokeAllRolesCommand; +import edu.harvard.iq.dataverse.engine.command.impl.*; import edu.harvard.iq.dataverse.util.FileUtil; import static edu.harvard.iq.dataverse.util.json.JsonPrinter.json; import java.util.Arrays; import java.util.List; +import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; + +import edu.harvard.iq.dataverse.util.json.JsonUtil; import jakarta.ejb.Stateless; import jakarta.json.JsonArray; +import jakarta.json.JsonObject; import jakarta.json.JsonObjectBuilder; import jakarta.ws.rs.*; import jakarta.ws.rs.container.ContainerRequestContext; -import jakarta.ws.rs.core.Context; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Request; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.Variant; +import jakarta.ws.rs.core.*; /** * @@ -261,4 +258,31 @@ public Response getTracesElement(@Context ContainerRequestContext crc, @Context } } + @POST + @AuthRequired + @Path("register") + public Response registerOidcUser(@Context ContainerRequestContext crc, String body) { + Optional bearerToken = getRequestBearerToken(crc); + if (bearerToken.isEmpty()) { + return error(Response.Status.BAD_REQUEST, "Bearer token required."); + } + JsonObject userJson; + try { + userJson = JsonUtil.getJsonObject(body); + execCommand(new RegisterOidcUserCommand(createDataverseRequest(getRequestUser(crc)), bearerToken.get(), jsonParser().parseUserDTO(userJson))); + return ok("User registered."); + } catch (Exception e){ + return error(Response.Status.BAD_REQUEST, "Error calling RegisterOidcUserCommand: " + e.getLocalizedMessage()); + } + + } + + // TODO: Remove duplication with BearerTokenAuthMechanism + private Optional getRequestBearerToken(ContainerRequestContext containerRequestContext) { + String headerParamBearerToken = containerRequestContext.getHeaderString(HttpHeaders.AUTHORIZATION); + if (headerParamBearerToken != null && headerParamBearerToken.toLowerCase().startsWith("Bearer" + " ")) { + return Optional.of(headerParamBearerToken); + } + return Optional.empty(); + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java b/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java new file mode 100644 index 00000000000..d829b099ff5 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java @@ -0,0 +1,13 @@ +package edu.harvard.iq.dataverse.api.dto; + +public class UserDTO { + private String email; + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java new file mode 100644 index 00000000000..4574784bd4b --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java @@ -0,0 +1,35 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.DvObject; +import edu.harvard.iq.dataverse.api.dto.UserDTO; +import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; +import edu.harvard.iq.dataverse.authorization.users.User; +import edu.harvard.iq.dataverse.engine.command.*; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; + +@RequiredPermissions({}) +public class RegisterOidcUserCommand extends AbstractVoidCommand { + + private final String bearerToken; + private final UserDTO userDTO; + + public RegisterOidcUserCommand(DataverseRequest aRequest, String bearerToken, UserDTO userDTO) { + super(aRequest, (DvObject) null); + this.bearerToken = bearerToken; + this.userDTO = userDTO; + } + + @Override + protected void executeImpl(CommandContext ctxt) throws CommandException { + try { + User user = ctxt.authentication().lookupUserByOidcBearerToken(bearerToken); + if (user != null) { + throw new IllegalCommandException("User is already registered with this token", this); + } + // TODO register user + } catch (AuthorizationException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java index 2f01c9bc2f2..e6a6f2d565f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java @@ -20,6 +20,7 @@ import edu.harvard.iq.dataverse.TermsOfUseAndAccess; import edu.harvard.iq.dataverse.api.Util; import edu.harvard.iq.dataverse.api.dto.FieldDTO; +import edu.harvard.iq.dataverse.api.dto.UserDTO; import edu.harvard.iq.dataverse.authorization.groups.impl.ipaddress.IpGroup; import edu.harvard.iq.dataverse.authorization.groups.impl.ipaddress.ip.IpAddress; import edu.harvard.iq.dataverse.authorization.groups.impl.ipaddress.ip.IpAddressRange; @@ -1052,4 +1053,8 @@ private void validate(String objectName, JsonObject jobject, String fieldName, V throw new JsonParseException( objectName + " missing a field named '"+fieldName+"' of type " + expectedValueType ); } } + + public UserDTO parseUserDTO(JsonObject jobj) { + return new UserDTO(); + } } From 2ca0722eabd3ff77a8cb19c943babd355f8e65bf Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 1 Nov 2024 19:40:17 +0000 Subject: [PATCH 07/62] Added: user creation logic to RegisterOidcUserCommand and missing fields to UserDTO --- .../harvard/iq/dataverse/api/dto/UserDTO.java | 37 ++++++++++++++++--- .../AuthenticationServiceBean.java | 2 +- .../command/impl/RegisterOidcUserCommand.java | 14 ++++++- 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java b/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java index d829b099ff5..c81fc99d549 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java @@ -1,13 +1,40 @@ package edu.harvard.iq.dataverse.api.dto; public class UserDTO { - private String email; + public String username; + public String firstName; + public String lastName; + public String emailAddress; - public String getEmail() { - return email; + public String getUsername() { + return username; } - public void setEmail(String email) { - this.email = email; + public void setUsername(String username) { + this.username = username; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getEmailAddress() { + return emailAddress; + } + + public void setEmailAddress(String emailAddress) { + this.emailAddress = emailAddress; } } diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java index 14caea5399b..50dd89700e9 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -1001,7 +1001,7 @@ public AuthenticatedUser lookupUserByOidcBearerToken(String bearerToken) throws * @return A {@link UserRecordIdentifier} representing the user associated with the valid token. * @throws AuthorizationException If the token is invalid or if no OIDC providers are available. */ - private UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String bearerToken) throws AuthorizationException { + public UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String bearerToken) throws AuthorizationException { try { BearerAccessToken accessToken = BearerAccessToken.parse(bearerToken); List providers = getAvailableOidcProviders(); diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java index 4574784bd4b..72e742ad077 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java @@ -2,6 +2,8 @@ import edu.harvard.iq.dataverse.DvObject; import edu.harvard.iq.dataverse.api.dto.UserDTO; +import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; +import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.engine.command.*; @@ -23,11 +25,19 @@ public RegisterOidcUserCommand(DataverseRequest aRequest, String bearerToken, Us @Override protected void executeImpl(CommandContext ctxt) throws CommandException { try { - User user = ctxt.authentication().lookupUserByOidcBearerToken(bearerToken); + UserRecordIdentifier userRecordIdentifier = ctxt.authentication().verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); + User user = ctxt.authentication().lookupUser(userRecordIdentifier); if (user != null) { throw new IllegalCommandException("User is already registered with this token", this); } - // TODO register user + AuthenticatedUserDisplayInfo authenticatedUserDisplayInfo = new AuthenticatedUserDisplayInfo( + userDTO.firstName, + userDTO.lastName, + userDTO.emailAddress, + "", + "" + ); + ctxt.authentication().createAuthenticatedUser(userRecordIdentifier, userDTO.username, authenticatedUserDisplayInfo, true); } catch (AuthorizationException e) { throw new RuntimeException(e); } From e382a1533a7ba42efde66429cc2542d1d67fed7c Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 1 Nov 2024 19:51:59 +0000 Subject: [PATCH 08/62] Refactor: extracted response messages to Bundle.properties --- src/main/java/edu/harvard/iq/dataverse/api/Users.java | 9 +++++++-- src/main/java/propertyFiles/Bundle.properties | 4 ++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Users.java b/src/main/java/edu/harvard/iq/dataverse/api/Users.java index ef65363cd0d..63cd2019856 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Users.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Users.java @@ -10,6 +10,8 @@ import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.engine.command.impl.*; +import edu.harvard.iq.dataverse.settings.FeatureFlags; +import edu.harvard.iq.dataverse.util.BundleUtil; import edu.harvard.iq.dataverse.util.FileUtil; import static edu.harvard.iq.dataverse.util.json.JsonPrinter.json; @@ -262,15 +264,18 @@ public Response getTracesElement(@Context ContainerRequestContext crc, @Context @AuthRequired @Path("register") public Response registerOidcUser(@Context ContainerRequestContext crc, String body) { + if (!FeatureFlags.API_BEARER_AUTH.enabled()) { + return error(Response.Status.INTERNAL_SERVER_ERROR, BundleUtil.getStringFromBundle("users.api.errors.bearerAuthFeatureFlagDisabled")); + } Optional bearerToken = getRequestBearerToken(crc); if (bearerToken.isEmpty()) { - return error(Response.Status.BAD_REQUEST, "Bearer token required."); + return error(Response.Status.BAD_REQUEST, BundleUtil.getStringFromBundle("users.api.errors.bearerTokenRequired")); } JsonObject userJson; try { userJson = JsonUtil.getJsonObject(body); execCommand(new RegisterOidcUserCommand(createDataverseRequest(getRequestUser(crc)), bearerToken.get(), jsonParser().parseUserDTO(userJson))); - return ok("User registered."); + return ok(BundleUtil.getStringFromBundle("users.api.userRegistered")); } catch (Exception e){ return error(Response.Status.BAD_REQUEST, "Error calling RegisterOidcUserCommand: " + e.getLocalizedMessage()); } diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index 149e6a7e828..043cb5f6394 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3062,3 +3062,7 @@ openapi.exception.invalid.format=Invalid format {0}, currently supported formats openapi.exception=Supported format definition not found. openapi.exception.unaligned=Unaligned parameters on Headers [{0}] and Request [{1}] +#Users.java +users.api.errors.bearerAuthFeatureFlagDisabled=This endpoint is only available when bearer authentication feature flag is enabled. +users.api.errors.bearerTokenRequired=Bearer token required. +users.api.userRegistered=User registered. From fd68cd225082ab27a7723d7980223f5bfbad0853 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 1 Nov 2024 19:53:18 +0000 Subject: [PATCH 09/62] Refactor: error message string extracted to const --- .../engine/command/impl/RegisterOidcUserCommand.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java index 72e742ad077..69d24550e9f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java @@ -16,6 +16,8 @@ public class RegisterOidcUserCommand extends AbstractVoidCommand { private final String bearerToken; private final UserDTO userDTO; + private static final String ERROR_USER_ALREADY_REGISTERED_WITH_TOKEN = "User is already registered with this token"; + public RegisterOidcUserCommand(DataverseRequest aRequest, String bearerToken, UserDTO userDTO) { super(aRequest, (DvObject) null); this.bearerToken = bearerToken; @@ -28,7 +30,7 @@ protected void executeImpl(CommandContext ctxt) throws CommandException { UserRecordIdentifier userRecordIdentifier = ctxt.authentication().verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); User user = ctxt.authentication().lookupUser(userRecordIdentifier); if (user != null) { - throw new IllegalCommandException("User is already registered with this token", this); + throw new IllegalCommandException(ERROR_USER_ALREADY_REGISTERED_WITH_TOKEN, this); } AuthenticatedUserDisplayInfo authenticatedUserDisplayInfo = new AuthenticatedUserDisplayInfo( userDTO.firstName, From 6a7a3e1237729ab58c6ec7c984b5cef5d2523a32 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 1 Nov 2024 20:10:43 +0000 Subject: [PATCH 10/62] Changed: replaced string class constants with Bundle.properties strings --- .../api/auth/BearerTokenAuthMechanism.java | 5 ++--- .../authorization/AuthenticationServiceBean.java | 13 ++++--------- .../command/impl/RegisterOidcUserCommand.java | 13 ++++++++----- src/main/java/propertyFiles/Bundle.properties | 11 +++++++++++ .../api/auth/BearerTokenAuthMechanismTest.java | 4 ++-- .../AuthenticationServiceBeanTest.java | 8 ++++---- 6 files changed, 31 insertions(+), 23 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java index 0dd2b9e0f9f..0e353a8e404 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java @@ -7,6 +7,7 @@ import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.settings.FeatureFlags; +import edu.harvard.iq.dataverse.util.BundleUtil; import jakarta.inject.Inject; import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.core.HttpHeaders; @@ -19,8 +20,6 @@ public class BearerTokenAuthMechanism implements AuthMechanism { private static final String BEARER_AUTH_SCHEME = "Bearer"; private static final Logger logger = Logger.getLogger(BearerTokenAuthMechanism.class.getCanonicalName()); - public static final String RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER = "Bearer token is validated, but there is no linked user account"; - @Inject protected AuthenticationServiceBean authSvc; @Inject @@ -48,7 +47,7 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) if (authUser == null) { logger.log(Level.WARNING, "Bearer token detected, OIDC provider validated the token but no linked UserAccount"); - throw new WrappedForbiddenAuthErrorResponse(RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER); + throw new WrappedForbiddenAuthErrorResponse(BundleUtil.getStringFromBundle("bearerTokenAuthMechanism.errors.tokenValidatedButNoRegisteredUser")); } return userSvc.updateLastApiUseTime(authUser); diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java index 50dd89700e9..811a46730ee 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -45,7 +45,7 @@ import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; -import jakarta.annotation.PostConstruct; + import jakarta.ejb.EJB; import jakarta.ejb.EJBException; import jakarta.ejb.Stateless; @@ -125,11 +125,6 @@ public class AuthenticationServiceBean { @PersistenceContext(unitName = "VDCNet-ejbPU") EntityManager em; - public static final String ERROR_MESSAGE_UNAUTHORIZED_BEARER_TOKEN = "Unauthorized bearer token"; - public static final String ERROR_MESSAGE_INVALID_BEARER_TOKEN = "Could not parse bearer token"; - public static final String ERROR_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED = "Bearer token detected, no OIDC provider configured"; - - public AbstractOAuth2AuthenticationProvider getOAuth2Provider( String id ) { return authProvidersRegistrationService.getOAuth2AuthProvidersMap().get(id); } @@ -1009,7 +1004,7 @@ public UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String bea // Ensure at least one OIDC provider is configured to validate the token. if (providers.isEmpty()) { logger.log(Level.WARNING, "Bearer token detected, no OIDC provider configured"); - throw new AuthorizationException(ERROR_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED); + throw new AuthorizationException(BundleUtil.getStringFromBundle("authenticationServiceBean.errors.bearerTokenDetectedNoOIDCProviderConfigured")); } // Attempt to validate the token with each configured OIDC provider. @@ -1029,12 +1024,12 @@ public UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String bea } } catch (ParseException e) { logger.log(Level.FINE, "Bearer token detected, unable to parse bearer token (invalid Token)", e); - throw new AuthorizationException(ERROR_MESSAGE_INVALID_BEARER_TOKEN); + throw new AuthorizationException(BundleUtil.getStringFromBundle("authenticationServiceBean.errors.invalidBearerToken")); } // If no provider validated the token, throw an authorization exception. logger.log(Level.FINE, "Bearer token detected, yet no configured OIDC provider validated it."); - throw new AuthorizationException(ERROR_MESSAGE_UNAUTHORIZED_BEARER_TOKEN); + throw new AuthorizationException(BundleUtil.getStringFromBundle("authenticationServiceBean.errors.unauthorizedBearerToken")); } /** diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java index 69d24550e9f..6b04e1bb15f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java @@ -9,6 +9,9 @@ import edu.harvard.iq.dataverse.engine.command.*; import edu.harvard.iq.dataverse.engine.command.exception.CommandException; import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; +import edu.harvard.iq.dataverse.engine.command.exception.PermissionException; +import edu.harvard.iq.dataverse.util.BundleUtil; +import jakarta.ejb.EJBException; @RequiredPermissions({}) public class RegisterOidcUserCommand extends AbstractVoidCommand { @@ -16,8 +19,6 @@ public class RegisterOidcUserCommand extends AbstractVoidCommand { private final String bearerToken; private final UserDTO userDTO; - private static final String ERROR_USER_ALREADY_REGISTERED_WITH_TOKEN = "User is already registered with this token"; - public RegisterOidcUserCommand(DataverseRequest aRequest, String bearerToken, UserDTO userDTO) { super(aRequest, (DvObject) null); this.bearerToken = bearerToken; @@ -30,7 +31,7 @@ protected void executeImpl(CommandContext ctxt) throws CommandException { UserRecordIdentifier userRecordIdentifier = ctxt.authentication().verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); User user = ctxt.authentication().lookupUser(userRecordIdentifier); if (user != null) { - throw new IllegalCommandException(ERROR_USER_ALREADY_REGISTERED_WITH_TOKEN, this); + throw new IllegalCommandException(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userAlreadyRegisteredWithToken"), this); } AuthenticatedUserDisplayInfo authenticatedUserDisplayInfo = new AuthenticatedUserDisplayInfo( userDTO.firstName, @@ -40,8 +41,10 @@ protected void executeImpl(CommandContext ctxt) throws CommandException { "" ); ctxt.authentication().createAuthenticatedUser(userRecordIdentifier, userDTO.username, authenticatedUserDisplayInfo, true); - } catch (AuthorizationException e) { - throw new RuntimeException(e); + } catch (AuthorizationException authorizationException) { + throw new PermissionException(authorizationException.getMessage(), this, null, null); + } catch (EJBException ejbException) { + throw new CommandException(ejbException.getMessage(), this); } } } diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index 043cb5f6394..97ff7ddebaa 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3066,3 +3066,14 @@ openapi.exception.unaligned=Unaligned parameters on Headers [{0}] and Request [{ users.api.errors.bearerAuthFeatureFlagDisabled=This endpoint is only available when bearer authentication feature flag is enabled. users.api.errors.bearerTokenRequired=Bearer token required. users.api.userRegistered=User registered. + +#RegisterOidcUserCommand.java +registerOidcUserCommand.errors.userAlreadyRegisteredWithToken=User is already registered with this token. + +#BearerTokenAuthMechanism.java +bearerTokenAuthMechanism.errors.tokenValidatedButNoRegisteredUser=Bearer token is validated, but there is no linked user account. + +#AuthenticationServiceBean.java +authenticationServiceBean.errors.unauthorizedBearerToken=Unauthorized bearer token. +authenticationServiceBean.errors.invalidBearerToken=Could not parse bearer token. +authenticationServiceBean.errors.bearerTokenDetectedNoOIDCProviderConfigured=Bearer token detected, no OIDC provider configured. diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java index c8a1ef8f087..b6f4ec922dd 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java @@ -7,6 +7,7 @@ import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.settings.JvmSettings; +import edu.harvard.iq.dataverse.util.BundleUtil; import edu.harvard.iq.dataverse.util.testing.JvmSetting; import edu.harvard.iq.dataverse.util.testing.LocalJvmSettings; import org.junit.jupiter.api.BeforeEach; @@ -15,7 +16,6 @@ import jakarta.ws.rs.container.ContainerRequestContext; -import static edu.harvard.iq.dataverse.api.auth.BearerTokenAuthMechanism.*; import static org.junit.jupiter.api.Assertions.*; @LocalJvmSettings @@ -78,6 +78,6 @@ void testFindUserFromRequest_validToken_noAccount() throws AuthorizationExceptio WrappedForbiddenAuthErrorResponse wrappedForbiddenAuthErrorResponse = assertThrows(WrappedForbiddenAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertEquals(RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER, wrappedForbiddenAuthErrorResponse.getMessage()); + assertEquals(BundleUtil.getStringFromBundle("bearerTokenAuthMechanism.errors.tokenValidatedButNoRegisteredUser"), wrappedForbiddenAuthErrorResponse.getMessage()); } } diff --git a/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java b/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java index be98bcb516d..b2e4767a27d 100644 --- a/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java @@ -6,6 +6,7 @@ import edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthProvider; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.User; +import edu.harvard.iq.dataverse.util.BundleUtil; import jakarta.persistence.EntityManager; import jakarta.persistence.NoResultException; import jakarta.persistence.TypedQuery; @@ -17,7 +18,6 @@ import java.util.Map; import java.util.Optional; -import static edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean.*; import static org.junit.jupiter.api.Assertions.*; public class AuthenticationServiceBeanTest { @@ -42,7 +42,7 @@ void testLookupUserByOidcBearerToken_no_OidcProvider() { () -> sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)); // Then the exception message should indicate no OIDC provider is configured - assertEquals(ERROR_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED, exception.getMessage()); + assertEquals(BundleUtil.getStringFromBundle("authenticationServiceBean.errors.bearerTokenDetectedNoOIDCProviderConfigured"), exception.getMessage()); } @Test @@ -57,7 +57,7 @@ void testLookupUserByOidcBearerToken_oneProvider_invalidToken_1() throws ParseEx () -> sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)); // Then the exception message should indicate an unauthorized token - assertEquals(ERROR_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, exception.getMessage()); + assertEquals(BundleUtil.getStringFromBundle("authenticationServiceBean.errors.unauthorizedBearerToken"), exception.getMessage()); } @Test @@ -72,7 +72,7 @@ void testLookupUserByOidcBearerToken_oneProvider_invalidToken_2() throws ParseEx () -> sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)); // Then the exception message should indicate an unauthorized token - assertEquals(ERROR_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, exception.getMessage()); + assertEquals(BundleUtil.getStringFromBundle("authenticationServiceBean.errors.unauthorizedBearerToken"), exception.getMessage()); } @Test From 35396773c9f99f06c45c0627d155ad66419c1f34 Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 4 Nov 2024 13:24:54 +0000 Subject: [PATCH 11/62] Added: managing user terms acceptance in registration --- .../java/edu/harvard/iq/dataverse/api/dto/UserDTO.java | 9 +++++++++ .../engine/command/impl/RegisterOidcUserCommand.java | 3 +++ src/main/java/propertyFiles/Bundle.properties | 1 + 3 files changed, 13 insertions(+) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java b/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java index c81fc99d549..ff57f176c4f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java @@ -5,6 +5,7 @@ public class UserDTO { public String firstName; public String lastName; public String emailAddress; + public boolean termsAccepted; public String getUsername() { return username; @@ -37,4 +38,12 @@ public String getEmailAddress() { public void setEmailAddress(String emailAddress) { this.emailAddress = emailAddress; } + + public boolean isTermsAccepted() { + return termsAccepted; + } + + public void setTermsAccepted(boolean termsAccepted) { + this.termsAccepted = termsAccepted; + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java index 6b04e1bb15f..ddd99d5961c 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java @@ -27,6 +27,9 @@ public RegisterOidcUserCommand(DataverseRequest aRequest, String bearerToken, Us @Override protected void executeImpl(CommandContext ctxt) throws CommandException { + if (!userDTO.termsAccepted) { + throw new IllegalCommandException(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms"), this); + } try { UserRecordIdentifier userRecordIdentifier = ctxt.authentication().verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); User user = ctxt.authentication().lookupUser(userRecordIdentifier); diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index 97ff7ddebaa..72dfbb55531 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3069,6 +3069,7 @@ users.api.userRegistered=User registered. #RegisterOidcUserCommand.java registerOidcUserCommand.errors.userAlreadyRegisteredWithToken=User is already registered with this token. +registerOidcUserCommand.errors.userShouldAcceptTerms=User should accept Dataverse General Terms of Use. #BearerTokenAuthMechanism.java bearerTokenAuthMechanism.errors.tokenValidatedButNoRegisteredUser=Bearer token is validated, but there is no linked user account. From 43805f00cccf3fc67858328743e42cc578b9657d Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 4 Nov 2024 13:29:52 +0000 Subject: [PATCH 12/62] Refactor: registerOidcUser endpoint --- .../java/edu/harvard/iq/dataverse/api/Users.java | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Users.java b/src/main/java/edu/harvard/iq/dataverse/api/Users.java index 88b04811d00..4da295b8c17 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Users.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Users.java @@ -276,15 +276,11 @@ public Response registerOidcUser(@Context ContainerRequestContext crc, String bo if (bearerToken.isEmpty()) { return error(Response.Status.BAD_REQUEST, BundleUtil.getStringFromBundle("users.api.errors.bearerTokenRequired")); } - JsonObject userJson; - try { - userJson = JsonUtil.getJsonObject(body); - execCommand(new RegisterOidcUserCommand(createDataverseRequest(getRequestUser(crc)), bearerToken.get(), jsonParser().parseUserDTO(userJson))); + return response(req -> { + JsonObject userJson = JsonUtil.getJsonObject(body); + execCommand(new RegisterOidcUserCommand(req, bearerToken.get(), jsonParser().parseUserDTO(userJson))); return ok(BundleUtil.getStringFromBundle("users.api.userRegistered")); - } catch (Exception e){ - return error(Response.Status.BAD_REQUEST, "Error calling RegisterOidcUserCommand: " + e.getLocalizedMessage()); - } - + }, getRequestUser(crc)); } // TODO: Remove duplication with BearerTokenAuthMechanism From 63790dbfbedb531fe730abae15e76b5fcdc4db1c Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 4 Nov 2024 13:43:37 +0000 Subject: [PATCH 13/62] Refactor: getRequestBearerToken extracted to AuthUtil --- .../edu/harvard/iq/dataverse/api/Users.java | 10 +------- .../iq/dataverse/api/auth/AuthUtil.java | 24 +++++++++++++++++++ .../api/auth/BearerTokenAuthMechanism.java | 20 +++------------- 3 files changed, 28 insertions(+), 26 deletions(-) create mode 100644 src/main/java/edu/harvard/iq/dataverse/api/auth/AuthUtil.java diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Users.java b/src/main/java/edu/harvard/iq/dataverse/api/Users.java index 4da295b8c17..d1bf5160fea 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Users.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Users.java @@ -14,6 +14,7 @@ import edu.harvard.iq.dataverse.util.BundleUtil; import edu.harvard.iq.dataverse.util.FileUtil; +import static edu.harvard.iq.dataverse.api.auth.AuthUtil.getRequestBearerToken; import static edu.harvard.iq.dataverse.util.json.JsonPrinter.json; import java.util.Arrays; @@ -282,13 +283,4 @@ public Response registerOidcUser(@Context ContainerRequestContext crc, String bo return ok(BundleUtil.getStringFromBundle("users.api.userRegistered")); }, getRequestUser(crc)); } - - // TODO: Remove duplication with BearerTokenAuthMechanism - private Optional getRequestBearerToken(ContainerRequestContext containerRequestContext) { - String headerParamBearerToken = containerRequestContext.getHeaderString(HttpHeaders.AUTHORIZATION); - if (headerParamBearerToken != null && headerParamBearerToken.toLowerCase().startsWith("Bearer" + " ")) { - return Optional.of(headerParamBearerToken); - } - return Optional.empty(); - } } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthUtil.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthUtil.java new file mode 100644 index 00000000000..267b6e86a8c --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthUtil.java @@ -0,0 +1,24 @@ +package edu.harvard.iq.dataverse.api.auth; + +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.core.HttpHeaders; + +import java.util.Optional; + +public class AuthUtil { + + private static final String BEARER_AUTH_SCHEME = "Bearer"; + + /** + * Retrieve the raw, encoded token value from the Authorization Bearer HTTP header as defined in RFC 6750 + * + * @return An {@link Optional} either empty if not present or the raw token from the header + */ + public static Optional getRequestBearerToken(ContainerRequestContext containerRequestContext) { + String headerParamBearerToken = containerRequestContext.getHeaderString(HttpHeaders.AUTHORIZATION); + if (headerParamBearerToken != null && headerParamBearerToken.toLowerCase().startsWith(BEARER_AUTH_SCHEME.toLowerCase() + " ")) { + return Optional.of(headerParamBearerToken); + } + return Optional.empty(); + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java index 0e353a8e404..d48a25824ec 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java @@ -10,14 +10,14 @@ import edu.harvard.iq.dataverse.util.BundleUtil; import jakarta.inject.Inject; import jakarta.ws.rs.container.ContainerRequestContext; -import jakarta.ws.rs.core.HttpHeaders; import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; +import static edu.harvard.iq.dataverse.api.auth.AuthUtil.getRequestBearerToken; + public class BearerTokenAuthMechanism implements AuthMechanism { - private static final String BEARER_AUTH_SCHEME = "Bearer"; private static final Logger logger = Logger.getLogger(BearerTokenAuthMechanism.class.getCanonicalName()); @Inject @@ -45,24 +45,10 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) } if (authUser == null) { - logger.log(Level.WARNING, - "Bearer token detected, OIDC provider validated the token but no linked UserAccount"); + logger.log(Level.WARNING, "Bearer token detected, OIDC provider validated the token but no linked UserAccount"); throw new WrappedForbiddenAuthErrorResponse(BundleUtil.getStringFromBundle("bearerTokenAuthMechanism.errors.tokenValidatedButNoRegisteredUser")); } return userSvc.updateLastApiUseTime(authUser); } - - /** - * Retrieve the raw, encoded token value from the Authorization Bearer HTTP header as defined in RFC 6750 - * - * @return An {@link Optional} either empty if not present or the raw token from the header - */ - private Optional getRequestBearerToken(ContainerRequestContext containerRequestContext) { - String headerParamBearerToken = containerRequestContext.getHeaderString(HttpHeaders.AUTHORIZATION); - if (headerParamBearerToken != null && headerParamBearerToken.toLowerCase().startsWith(BEARER_AUTH_SCHEME.toLowerCase() + " ")) { - return Optional.of(headerParamBearerToken); - } - return Optional.empty(); - } } From 37afa9864fe9878e6b481a3ff558d5d7a8bdc88b Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 4 Nov 2024 13:45:55 +0000 Subject: [PATCH 14/62] Fixed: priority order in CompoundAuthMechanism to allow session and bearer token auth feature flags compatibility --- .../harvard/iq/dataverse/api/auth/CompoundAuthMechanism.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanism.java index 801e2752b9e..e5be5144897 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanism.java @@ -5,6 +5,7 @@ import jakarta.inject.Inject; import jakarta.ws.rs.container.ContainerRequestContext; + import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -19,9 +20,9 @@ public class CompoundAuthMechanism implements AuthMechanism { private final List authMechanisms = new ArrayList<>(); @Inject - public CompoundAuthMechanism(ApiKeyAuthMechanism apiKeyAuthMechanism, WorkflowKeyAuthMechanism workflowKeyAuthMechanism, SignedUrlAuthMechanism signedUrlAuthMechanism, SessionCookieAuthMechanism sessionCookieAuthMechanism, BearerTokenAuthMechanism bearerTokenAuthMechanism) { + public CompoundAuthMechanism(ApiKeyAuthMechanism apiKeyAuthMechanism, WorkflowKeyAuthMechanism workflowKeyAuthMechanism, SignedUrlAuthMechanism signedUrlAuthMechanism, BearerTokenAuthMechanism bearerTokenAuthMechanism, SessionCookieAuthMechanism sessionCookieAuthMechanism) { // Auth mechanisms should be ordered by priority here - add(apiKeyAuthMechanism, workflowKeyAuthMechanism, signedUrlAuthMechanism, sessionCookieAuthMechanism,bearerTokenAuthMechanism); + add(apiKeyAuthMechanism, workflowKeyAuthMechanism, signedUrlAuthMechanism, bearerTokenAuthMechanism, sessionCookieAuthMechanism); } public CompoundAuthMechanism(AuthMechanism... authMechanisms) { From 004155233646c08393b22b93d224467feea76474 Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 4 Nov 2024 15:57:51 +0000 Subject: [PATCH 15/62] Added: completed UserDTO fields --- .../harvard/iq/dataverse/api/dto/UserDTO.java | 28 +++++++++++++++---- .../command/impl/RegisterOidcUserCommand.java | 15 +++++----- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java b/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java index ff57f176c4f..df1920c4d25 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java @@ -1,11 +1,13 @@ package edu.harvard.iq.dataverse.api.dto; public class UserDTO { - public String username; - public String firstName; - public String lastName; - public String emailAddress; - public boolean termsAccepted; + private String username; + private String firstName; + private String lastName; + private String emailAddress; + private String affiliation; + private String position; + private boolean termsAccepted; public String getUsername() { return username; @@ -39,6 +41,22 @@ public void setEmailAddress(String emailAddress) { this.emailAddress = emailAddress; } + public String getAffiliation() { + return affiliation; + } + + public void setAffiliation(String affiliation) { + this.affiliation = affiliation; + } + + public String getPosition() { + return position; + } + + public void setPosition(String position) { + this.position = position; + } + public boolean isTermsAccepted() { return termsAccepted; } diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java index ddd99d5961c..cfa7eccc284 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java @@ -27,9 +27,10 @@ public RegisterOidcUserCommand(DataverseRequest aRequest, String bearerToken, Us @Override protected void executeImpl(CommandContext ctxt) throws CommandException { - if (!userDTO.termsAccepted) { + if (!userDTO.isTermsAccepted()) { throw new IllegalCommandException(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms"), this); } + // TODO check username and email not already in use try { UserRecordIdentifier userRecordIdentifier = ctxt.authentication().verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); User user = ctxt.authentication().lookupUser(userRecordIdentifier); @@ -37,13 +38,13 @@ protected void executeImpl(CommandContext ctxt) throws CommandException { throw new IllegalCommandException(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userAlreadyRegisteredWithToken"), this); } AuthenticatedUserDisplayInfo authenticatedUserDisplayInfo = new AuthenticatedUserDisplayInfo( - userDTO.firstName, - userDTO.lastName, - userDTO.emailAddress, - "", - "" + userDTO.getFirstName(), + userDTO.getLastName(), + userDTO.getEmailAddress(), + userDTO.getAffiliation(), + userDTO.getPosition() ); - ctxt.authentication().createAuthenticatedUser(userRecordIdentifier, userDTO.username, authenticatedUserDisplayInfo, true); + ctxt.authentication().createAuthenticatedUser(userRecordIdentifier, userDTO.getUsername(), authenticatedUserDisplayInfo, true); } catch (AuthorizationException authorizationException) { throw new PermissionException(authorizationException.getMessage(), this, null, null); } catch (EJBException ejbException) { From a021c9be96150d9b33170067c1ca967cf1d15811 Mon Sep 17 00:00:00 2001 From: GPortas Date: Tue, 5 Nov 2024 13:11:15 +0000 Subject: [PATCH 16/62] Added: json parse logic for register user --- .../edu/harvard/iq/dataverse/api/Users.java | 8 +++++- .../iq/dataverse/util/json/JsonParser.java | 27 +++++++++++++++---- src/main/java/propertyFiles/Bundle.properties | 1 + 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Users.java b/src/main/java/edu/harvard/iq/dataverse/api/Users.java index d1bf5160fea..5bc92a88180 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Users.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Users.java @@ -17,12 +17,14 @@ import static edu.harvard.iq.dataverse.api.auth.AuthUtil.getRequestBearerToken; import static edu.harvard.iq.dataverse.util.json.JsonPrinter.json; +import java.text.MessageFormat; import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; +import edu.harvard.iq.dataverse.util.json.JsonParseException; import edu.harvard.iq.dataverse.util.json.JsonUtil; import jakarta.ejb.Stateless; import jakarta.json.JsonArray; @@ -279,7 +281,11 @@ public Response registerOidcUser(@Context ContainerRequestContext crc, String bo } return response(req -> { JsonObject userJson = JsonUtil.getJsonObject(body); - execCommand(new RegisterOidcUserCommand(req, bearerToken.get(), jsonParser().parseUserDTO(userJson))); + try { + execCommand(new RegisterOidcUserCommand(req, bearerToken.get(), jsonParser().parseUserDTO(userJson))); + } catch (JsonParseException e) { + return error(Response.Status.BAD_REQUEST, MessageFormat.format(BundleUtil.getStringFromBundle("users.api.errors.jsonParseToUserDTO"), e.getMessage())); + } return ok(BundleUtil.getStringFromBundle("users.api.userRegistered")); }, getRequestUser(crc)); } diff --git a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java index e6a6f2d565f..caed0c4029c 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java @@ -49,6 +49,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.Function; import java.util.logging.Logger; import java.util.stream.Collectors; import jakarta.json.Json; @@ -241,11 +242,19 @@ public DataverseTheme parseDataverseTheme(JsonObject obj) { return theme; } - private static String getMandatoryString(JsonObject jobj, String name) throws JsonParseException { + private static T getMandatoryField(JsonObject jobj, String name, Function getter) throws JsonParseException { if (jobj.containsKey(name)) { - return jobj.getString(name); + return getter.apply(name); } - throw new JsonParseException("Field " + name + " is mandatory"); + throw new JsonParseException("Field '" + name + "' is mandatory"); + } + + private static String getMandatoryString(JsonObject jobj, String name) throws JsonParseException { + return getMandatoryField(jobj, name, jobj::getString); + } + + private static Boolean getMandatoryBoolean(JsonObject jobj, String name) throws JsonParseException { + return getMandatoryField(jobj, name, jobj::getBoolean); } public IpGroup parseIpGroup(JsonObject obj) { @@ -1054,7 +1063,15 @@ private void validate(String objectName, JsonObject jobject, String fieldName, V } } - public UserDTO parseUserDTO(JsonObject jobj) { - return new UserDTO(); + public UserDTO parseUserDTO(JsonObject jobj) throws JsonParseException { + UserDTO userDTO = new UserDTO(); + userDTO.setUsername(getMandatoryString(jobj, "username")); + userDTO.setEmailAddress(getMandatoryString(jobj, "emailAddress")); + userDTO.setFirstName(getMandatoryString(jobj, "firstName")); + userDTO.setLastName(getMandatoryString(jobj, "lastName")); + userDTO.setTermsAccepted(getMandatoryBoolean(jobj, "termsAccepted")); + userDTO.setAffiliation(jobj.getString("affiliation")); + userDTO.setPosition(jobj.getString("position")); + return userDTO; } } diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index 72dfbb55531..27e96d4318f 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3065,6 +3065,7 @@ openapi.exception.unaligned=Unaligned parameters on Headers [{0}] and Request [{ #Users.java users.api.errors.bearerAuthFeatureFlagDisabled=This endpoint is only available when bearer authentication feature flag is enabled. users.api.errors.bearerTokenRequired=Bearer token required. +users.api.errors.jsonParseToUserDTO=Error parsing the POSTed User json: {0} users.api.userRegistered=User registered. #RegisterOidcUserCommand.java From bf601e68bcf00fc3c9437c758b94864abca8a757 Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 6 Nov 2024 13:34:19 +0000 Subject: [PATCH 17/62] Added: fields validation to RegisterOidcUserCommand --- .../iq/dataverse/api/AbstractApiBean.java | 24 +++++-- .../InvalidFieldsCommandException.java | 42 ++++++++++++ .../command/impl/RegisterOidcUserCommand.java | 67 +++++++++++++++---- src/main/java/propertyFiles/Bundle.properties | 5 +- 4 files changed, 119 insertions(+), 19 deletions(-) create mode 100644 src/main/java/edu/harvard/iq/dataverse/engine/command/exception/InvalidFieldsCommandException.java diff --git a/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java b/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java index 3257a3cc7ac..d34eb1755b6 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java @@ -14,14 +14,11 @@ import edu.harvard.iq.dataverse.dataset.DatasetTypeServiceBean; import edu.harvard.iq.dataverse.engine.command.Command; import edu.harvard.iq.dataverse.engine.command.DataverseRequest; -import edu.harvard.iq.dataverse.engine.command.exception.CommandException; -import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; -import edu.harvard.iq.dataverse.engine.command.exception.PermissionException; +import edu.harvard.iq.dataverse.engine.command.exception.*; import edu.harvard.iq.dataverse.engine.command.impl.GetDraftDatasetVersionCommand; import edu.harvard.iq.dataverse.engine.command.impl.GetLatestAccessibleDatasetVersionCommand; import edu.harvard.iq.dataverse.engine.command.impl.GetLatestPublishedDatasetVersionCommand; import edu.harvard.iq.dataverse.engine.command.impl.GetSpecificPublishedDatasetVersionCommand; -import edu.harvard.iq.dataverse.engine.command.exception.RateLimitCommandException; import edu.harvard.iq.dataverse.externaltools.ExternalToolServiceBean; import edu.harvard.iq.dataverse.license.LicenseServiceBean; import edu.harvard.iq.dataverse.pidproviders.PidUtil; @@ -56,6 +53,7 @@ import java.net.URI; import java.util.Arrays; import java.util.Collections; +import java.util.Map; import java.util.UUID; import java.util.concurrent.Callable; import java.util.logging.Level; @@ -635,6 +633,8 @@ protected T execCommand( Command cmd ) throws WrappedResponse { throw new WrappedResponse(error(Response.Status.UNAUTHORIZED, "User " + cmd.getRequest().getUser().getIdentifier() + " is not permitted to perform requested action.") ); + } catch (InvalidFieldsCommandException ex) { + throw new WrappedResponse(ex, badRequest(ex.getMessage(), ex.getFieldErrors())); } catch (CommandException ex) { Logger.getLogger(AbstractApiBean.class.getName()).log(Level.SEVERE, "Error while executing command " + cmd, ex); throw new WrappedResponse(ex, error(Status.INTERNAL_SERVER_ERROR, ex.getMessage())); @@ -809,6 +809,22 @@ protected Response badRequest( String msg ) { return error( Status.BAD_REQUEST, msg ); } + protected Response badRequest(String msg, Map fieldErrors) { + JsonObject fieldErrorsJson = Json.createObjectBuilder() + .add("fieldErrors", Json.createObjectBuilder(fieldErrors).build()) + .build(); + + return Response.status(Status.BAD_REQUEST) + .entity(NullSafeJsonBuilder.jsonObjectBuilder() + .add("status", ApiConstants.STATUS_ERROR) + .add("message", msg) + .add("fieldErrors", fieldErrorsJson) + .build() + ) + .type(MediaType.APPLICATION_JSON_TYPE) + .build(); + } + protected Response forbidden( String msg ) { return error( Status.FORBIDDEN, msg ); } diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/exception/InvalidFieldsCommandException.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/exception/InvalidFieldsCommandException.java new file mode 100644 index 00000000000..9bd1869f8a9 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/exception/InvalidFieldsCommandException.java @@ -0,0 +1,42 @@ +package edu.harvard.iq.dataverse.engine.command.exception; + +import edu.harvard.iq.dataverse.engine.command.Command; +import java.util.Map; + +public class InvalidFieldsCommandException extends CommandException { + + private final Map fieldErrors; + + /** + * Constructs a new InvalidFieldsCommandException with the specified detail message, + * command, and a map of field errors. + * + * @param message The detail message. + * @param aCommand The command where the exception was encountered. + * @param fieldErrors A map containing the fields as keys and the reasons for their errors as values. + */ + public InvalidFieldsCommandException(String message, Command aCommand, Map fieldErrors) { + super(message, aCommand); + this.fieldErrors = fieldErrors; + } + + /** + * Gets the map of fields and their corresponding error messages. + * + * @return The map of field errors. + */ + public Map getFieldErrors() { + return fieldErrors; + } + + /** + * Returns a string representation of this exception, including the + * message and details of the invalid fields and their errors. + * + * @return A string representation of this exception. + */ + @Override + public String toString() { + return super.toString() + ", fieldErrors=" + fieldErrors; + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java index cfa7eccc284..99a2d5f6c82 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java @@ -5,13 +5,15 @@ import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; -import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.engine.command.*; import edu.harvard.iq.dataverse.engine.command.exception.CommandException; import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; import edu.harvard.iq.dataverse.engine.command.exception.PermissionException; +import edu.harvard.iq.dataverse.engine.command.exception.InvalidFieldsCommandException; import edu.harvard.iq.dataverse.util.BundleUtil; -import jakarta.ejb.EJBException; + +import java.util.HashMap; +import java.util.Map; @RequiredPermissions({}) public class RegisterOidcUserCommand extends AbstractVoidCommand { @@ -27,28 +29,65 @@ public RegisterOidcUserCommand(DataverseRequest aRequest, String bearerToken, Us @Override protected void executeImpl(CommandContext ctxt) throws CommandException { + Map fieldErrors = validateUserFields(ctxt); + + if (!fieldErrors.isEmpty()) { + throw new InvalidFieldsCommandException( + BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.invalidFields"), + this, + fieldErrors + ); + } + + createUser(ctxt); + } + + private Map validateUserFields(CommandContext ctxt) { + Map fieldErrors = new HashMap<>(); + if (!userDTO.isTermsAccepted()) { - throw new IllegalCommandException(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms"), this); + fieldErrors.put("termsAccepted", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms")); + } + + if (isEmailInUse(ctxt, userDTO.getEmailAddress())) { + fieldErrors.put("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailAddressInUse")); + } + + if (isUsernameInUse(ctxt, userDTO.getUsername())) { + fieldErrors.put("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.usernameInUse")); } - // TODO check username and email not already in use + + return fieldErrors; + } + + private boolean isEmailInUse(CommandContext ctxt, String emailAddress) { + return ctxt.authentication().getAuthenticatedUserByEmail(emailAddress) != null; + } + + private boolean isUsernameInUse(CommandContext ctxt, String username) { + return ctxt.authentication().getAuthenticatedUser(username) != null; + } + + private void createUser(CommandContext ctxt) throws CommandException { try { UserRecordIdentifier userRecordIdentifier = ctxt.authentication().verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); - User user = ctxt.authentication().lookupUser(userRecordIdentifier); - if (user != null) { + + if (ctxt.authentication().lookupUser(userRecordIdentifier) != null) { throw new IllegalCommandException(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userAlreadyRegisteredWithToken"), this); } - AuthenticatedUserDisplayInfo authenticatedUserDisplayInfo = new AuthenticatedUserDisplayInfo( + + AuthenticatedUserDisplayInfo userInfo = new AuthenticatedUserDisplayInfo( userDTO.getFirstName(), userDTO.getLastName(), userDTO.getEmailAddress(), - userDTO.getAffiliation(), - userDTO.getPosition() + userDTO.getAffiliation() != null ? userDTO.getAffiliation() : "", + userDTO.getPosition() != null ? userDTO.getPosition() : "" ); - ctxt.authentication().createAuthenticatedUser(userRecordIdentifier, userDTO.getUsername(), authenticatedUserDisplayInfo, true); - } catch (AuthorizationException authorizationException) { - throw new PermissionException(authorizationException.getMessage(), this, null, null); - } catch (EJBException ejbException) { - throw new CommandException(ejbException.getMessage(), this); + + ctxt.authentication().createAuthenticatedUser(userRecordIdentifier, userDTO.getUsername(), userInfo, true); + + } catch (AuthorizationException ex) { + throw new PermissionException(ex.getMessage(), this, null, null); } } } diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index 27e96d4318f..e5993ff3fad 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3070,7 +3070,10 @@ users.api.userRegistered=User registered. #RegisterOidcUserCommand.java registerOidcUserCommand.errors.userAlreadyRegisteredWithToken=User is already registered with this token. -registerOidcUserCommand.errors.userShouldAcceptTerms=User should accept Dataverse General Terms of Use. +registerOidcUserCommand.errors.invalidFields=The provided fields are invalid for registering a new user. +registerOidcUserCommand.errors.userShouldAcceptTerms=Should be accepted (true). +registerOidcUserCommand.errors.emailAddressInUse=Already in use. +registerOidcUserCommand.errors.usernameInUse=Already in use. #BearerTokenAuthMechanism.java bearerTokenAuthMechanism.errors.tokenValidatedButNoRegisteredUser=Bearer token is validated, but there is no linked user account. From e544221db900623637ef1087e1768ecdea1dc8ef Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 6 Nov 2024 15:14:31 +0000 Subject: [PATCH 18/62] Changed: Bundle.properties values --- src/main/java/propertyFiles/Bundle.properties | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index e5993ff3fad..1ae846c338e 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3071,9 +3071,9 @@ users.api.userRegistered=User registered. #RegisterOidcUserCommand.java registerOidcUserCommand.errors.userAlreadyRegisteredWithToken=User is already registered with this token. registerOidcUserCommand.errors.invalidFields=The provided fields are invalid for registering a new user. -registerOidcUserCommand.errors.userShouldAcceptTerms=Should be accepted (true). -registerOidcUserCommand.errors.emailAddressInUse=Already in use. -registerOidcUserCommand.errors.usernameInUse=Already in use. +registerOidcUserCommand.errors.userShouldAcceptTerms=Terms should be accepted. +registerOidcUserCommand.errors.emailAddressInUse=Email already in use. +registerOidcUserCommand.errors.usernameInUse=Username already in use. #BearerTokenAuthMechanism.java bearerTokenAuthMechanism.errors.tokenValidatedButNoRegisteredUser=Bearer token is validated, but there is no linked user account. From 753f6ebcfa54618579f14f244103a964290867fd Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 8 Nov 2024 10:25:31 +0000 Subject: [PATCH 19/62] Added: unit tests for RegisterOidcUserCommand --- .../impl/RegisterOidcUserCommandTest.java | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommandTest.java diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommandTest.java new file mode 100644 index 00000000000..bfc693cc308 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommandTest.java @@ -0,0 +1,166 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.api.dto.UserDTO; +import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; +import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; +import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; +import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.engine.command.CommandContext; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; +import edu.harvard.iq.dataverse.engine.command.exception.InvalidFieldsCommandException; +import edu.harvard.iq.dataverse.engine.command.exception.PermissionException; +import edu.harvard.iq.dataverse.util.BundleUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import static edu.harvard.iq.dataverse.mocks.MocksFactory.makeRequest; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +class RegisterOidcUserCommandTest { + + private static final String TEST_BEARER_TOKEN = "Bearer test"; + + private UserDTO userDTO; + + @Mock + private CommandContext context; + + @Mock + private AuthenticationServiceBean authServiceMock; + + @InjectMocks + private RegisterOidcUserCommand sut; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + setUpDefaultUserDTO(); + when(context.authentication()).thenReturn(authServiceMock); + sut = new RegisterOidcUserCommand(makeRequest(), TEST_BEARER_TOKEN, userDTO); + } + + private void setUpDefaultUserDTO() { + userDTO = new UserDTO(); + userDTO.setTermsAccepted(true); + userDTO.setFirstName("FirstName"); + userDTO.setLastName("LastName"); + userDTO.setUsername("username"); + userDTO.setEmailAddress("user@example.com"); + } + + @Test + public void execute_unacceptedTerms_availableEmailAndUsername() { + userDTO.setTermsAccepted(false); + when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(null); + when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(null); + + assertThatThrownBy(() -> sut.execute(context)) + .isInstanceOf(InvalidFieldsCommandException.class) + .satisfies(exception -> { + InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; + assertThat(ex.getFieldErrors()) + .containsEntry("termsAccepted", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms")) + .doesNotContainEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailAddressInUse")) + .doesNotContainEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.usernameInUse")); + }); + } + + @Test + public void execute_acceptedTerms_availableEmailAndUsername() { + AuthenticatedUser existingUser = new AuthenticatedUser(); + when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(existingUser); + when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(existingUser); + + assertThatThrownBy(() -> sut.execute(context)) + .isInstanceOf(InvalidFieldsCommandException.class) + .satisfies(exception -> { + InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; + assertThat(ex.getFieldErrors()) + .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailAddressInUse")) + .containsEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.usernameInUse")) + .doesNotContainKey("termsAccepted"); + }); + } + + @Test + void execute_throwsPermissionException_onAuthorizationException() throws AuthorizationException { + String testAuthorizationExceptionMessage = "Authorization failed"; + when(context.authentication().verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)) + .thenThrow(new AuthorizationException(testAuthorizationExceptionMessage)); + + assertThatThrownBy(() -> sut.execute(context)) + .isInstanceOf(PermissionException.class) + .hasMessageContaining(testAuthorizationExceptionMessage); + + Mockito.verify(context.authentication(), times(1)) + .verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN); + } + + @Test + void execute_throwsIllegalCommandException_ifUserAlreadyRegisteredWithToken() throws AuthorizationException { + UserRecordIdentifier userRecordIdentifierMock = Mockito.mock(UserRecordIdentifier.class); + when(context.authentication().verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)) + .thenReturn(userRecordIdentifierMock); + when(context.authentication().lookupUser(userRecordIdentifierMock)).thenReturn(new AuthenticatedUser()); + + assertThatThrownBy(() -> sut.execute(context)) + .isInstanceOf(IllegalCommandException.class) + .hasMessageContaining(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userAlreadyRegisteredWithToken")); + + Mockito.verify(context.authentication(), times(1)) + .lookupUser(userRecordIdentifierMock); + } + + @Test + void execute_happyPath_withoutAffiliationAndPosition() throws AuthorizationException, CommandException { + UserRecordIdentifier userRecordIdentifierMock = mock(UserRecordIdentifier.class); + when(authServiceMock.verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(userRecordIdentifierMock); + + sut.execute(context); + + verify(authServiceMock, times(1)).createAuthenticatedUser( + eq(userRecordIdentifierMock), + eq(userDTO.getUsername()), + eq(new AuthenticatedUserDisplayInfo( + userDTO.getFirstName(), + userDTO.getLastName(), + userDTO.getEmailAddress(), + "", + "") + ), + eq(true) + ); + } + + @Test + void execute_happyPath_withAffiliationAndPosition() throws AuthorizationException, CommandException { + userDTO.setPosition("test position"); + userDTO.setAffiliation("test affiliation"); + + UserRecordIdentifier userRecordIdentifierMock = mock(UserRecordIdentifier.class); + when(authServiceMock.verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(userRecordIdentifierMock); + + sut.execute(context); + + verify(authServiceMock, times(1)).createAuthenticatedUser( + eq(userRecordIdentifierMock), + eq(userDTO.getUsername()), + eq(new AuthenticatedUserDisplayInfo( + userDTO.getFirstName(), + userDTO.getLastName(), + userDTO.getEmailAddress(), + userDTO.getAffiliation(), + userDTO.getPosition()) + ), + eq(true) + ); + } +} From 15b78bb6d80d49107a48c01c853858b9407e3ffa Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 8 Nov 2024 15:03:03 +0000 Subject: [PATCH 20/62] Removed: unnecessary auth annotation on register endpoint --- .../edu/harvard/iq/dataverse/api/Users.java | 26 +++++++++---------- .../iq/dataverse/api/auth/AuthUtil.java | 14 +++++----- .../api/auth/BearerTokenAuthMechanism.java | 13 +++++++++- 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Users.java b/src/main/java/edu/harvard/iq/dataverse/api/Users.java index 5bc92a88180..c3aefe4746f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Users.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Users.java @@ -8,13 +8,14 @@ import edu.harvard.iq.dataverse.api.auth.AuthRequired; import edu.harvard.iq.dataverse.authorization.users.ApiToken; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.authorization.users.GuestUser; import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.engine.command.impl.*; import edu.harvard.iq.dataverse.settings.FeatureFlags; import edu.harvard.iq.dataverse.util.BundleUtil; import edu.harvard.iq.dataverse.util.FileUtil; -import static edu.harvard.iq.dataverse.api.auth.AuthUtil.getRequestBearerToken; +import static edu.harvard.iq.dataverse.api.auth.AuthUtil.extractBearerTokenFromHeaderParam; import static edu.harvard.iq.dataverse.util.json.JsonPrinter.json; import java.text.MessageFormat; @@ -269,24 +270,23 @@ public Response getTracesElement(@Context ContainerRequestContext crc, @Context } @POST - @AuthRequired @Path("register") - public Response registerOidcUser(@Context ContainerRequestContext crc, String body) { + public Response registerOidcUser(String body) { if (!FeatureFlags.API_BEARER_AUTH.enabled()) { return error(Response.Status.INTERNAL_SERVER_ERROR, BundleUtil.getStringFromBundle("users.api.errors.bearerAuthFeatureFlagDisabled")); } - Optional bearerToken = getRequestBearerToken(crc); + Optional bearerToken = extractBearerTokenFromHeaderParam(httpRequest.getHeader(HttpHeaders.AUTHORIZATION)); if (bearerToken.isEmpty()) { return error(Response.Status.BAD_REQUEST, BundleUtil.getStringFromBundle("users.api.errors.bearerTokenRequired")); } - return response(req -> { - JsonObject userJson = JsonUtil.getJsonObject(body); - try { - execCommand(new RegisterOidcUserCommand(req, bearerToken.get(), jsonParser().parseUserDTO(userJson))); - } catch (JsonParseException e) { - return error(Response.Status.BAD_REQUEST, MessageFormat.format(BundleUtil.getStringFromBundle("users.api.errors.jsonParseToUserDTO"), e.getMessage())); - } - return ok(BundleUtil.getStringFromBundle("users.api.userRegistered")); - }, getRequestUser(crc)); + JsonObject userJson = JsonUtil.getJsonObject(body); + try { + execCommand(new RegisterOidcUserCommand(createDataverseRequest(GuestUser.get()), bearerToken.get(), jsonParser().parseUserDTO(userJson))); + } catch (JsonParseException e) { + return error(Response.Status.BAD_REQUEST, MessageFormat.format(BundleUtil.getStringFromBundle("users.api.errors.jsonParseToUserDTO"), e.getMessage())); + } catch (WrappedResponse e) { + return e.getResponse(); + } + return ok(BundleUtil.getStringFromBundle("users.api.userRegistered")); } } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthUtil.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthUtil.java index 267b6e86a8c..36cd7c7f1df 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthUtil.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthUtil.java @@ -1,8 +1,5 @@ package edu.harvard.iq.dataverse.api.auth; -import jakarta.ws.rs.container.ContainerRequestContext; -import jakarta.ws.rs.core.HttpHeaders; - import java.util.Optional; public class AuthUtil { @@ -10,12 +7,15 @@ public class AuthUtil { private static final String BEARER_AUTH_SCHEME = "Bearer"; /** - * Retrieve the raw, encoded token value from the Authorization Bearer HTTP header as defined in RFC 6750 + * Extracts the Bearer token from the provided HTTP Authorization header value. + *

+ * Validates that the header value starts with the "Bearer" scheme as defined in RFC 6750. + * If the header is null, empty, or does not start with "Bearer ", an empty {@link Optional} is returned. * - * @return An {@link Optional} either empty if not present or the raw token from the header + * @param headerParamBearerToken the raw HTTP Authorization header value containing the Bearer token + * @return An {@link Optional} containing the raw Bearer token if present and valid; otherwise, an empty {@link Optional} */ - public static Optional getRequestBearerToken(ContainerRequestContext containerRequestContext) { - String headerParamBearerToken = containerRequestContext.getHeaderString(HttpHeaders.AUTHORIZATION); + public static Optional extractBearerTokenFromHeaderParam(String headerParamBearerToken) { if (headerParamBearerToken != null && headerParamBearerToken.toLowerCase().startsWith(BEARER_AUTH_SCHEME.toLowerCase() + " ")) { return Optional.of(headerParamBearerToken); } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java index d48a25824ec..9bfcb03a72b 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java @@ -10,12 +10,13 @@ import edu.harvard.iq.dataverse.util.BundleUtil; import jakarta.inject.Inject; import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.core.HttpHeaders; import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; -import static edu.harvard.iq.dataverse.api.auth.AuthUtil.getRequestBearerToken; +import static edu.harvard.iq.dataverse.api.auth.AuthUtil.extractBearerTokenFromHeaderParam; public class BearerTokenAuthMechanism implements AuthMechanism { private static final Logger logger = Logger.getLogger(BearerTokenAuthMechanism.class.getCanonicalName()); @@ -51,4 +52,14 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) return userSvc.updateLastApiUseTime(authUser); } + + /** + * Retrieve the raw, encoded token value from the Authorization Bearer HTTP header as defined in RFC 6750 + * + * @return An {@link Optional} either empty if not present or the raw token from the header + */ + public static Optional getRequestBearerToken(ContainerRequestContext containerRequestContext) { + String headerParamBearerToken = containerRequestContext.getHeaderString(HttpHeaders.AUTHORIZATION); + return extractBearerTokenFromHeaderParam(headerParamBearerToken); + } } From 415e23b648ead7cb6dc4f94c02b06790512f7fce Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 8 Nov 2024 17:48:56 +0000 Subject: [PATCH 21/62] Added: users register endpoint IT and fixes --- .../edu/harvard/iq/dataverse/api/Users.java | 5 +- .../edu/harvard/iq/dataverse/api/UsersIT.java | 125 ++++++++++++++++++ .../edu/harvard/iq/dataverse/api/UtilIT.java | 9 ++ 3 files changed, 137 insertions(+), 2 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Users.java b/src/main/java/edu/harvard/iq/dataverse/api/Users.java index c3aefe4746f..cc9dee3b678 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Users.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Users.java @@ -31,6 +31,7 @@ import jakarta.json.JsonArray; import jakarta.json.JsonObject; import jakarta.json.JsonObjectBuilder; +import jakarta.json.stream.JsonParsingException; import jakarta.ws.rs.*; import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.core.*; @@ -279,10 +280,10 @@ public Response registerOidcUser(String body) { if (bearerToken.isEmpty()) { return error(Response.Status.BAD_REQUEST, BundleUtil.getStringFromBundle("users.api.errors.bearerTokenRequired")); } - JsonObject userJson = JsonUtil.getJsonObject(body); try { + JsonObject userJson = JsonUtil.getJsonObject(body); execCommand(new RegisterOidcUserCommand(createDataverseRequest(GuestUser.get()), bearerToken.get(), jsonParser().parseUserDTO(userJson))); - } catch (JsonParseException e) { + } catch (JsonParseException | JsonParsingException e) { return error(Response.Status.BAD_REQUEST, MessageFormat.format(BundleUtil.getStringFromBundle("users.api.errors.jsonParseToUserDTO"), e.getMessage())); } catch (WrappedResponse e) { return e.getResponse(); diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java index ce3b8bf75ff..b91281632f3 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java @@ -1,5 +1,7 @@ package edu.harvard.iq.dataverse.api; +import edu.harvard.iq.dataverse.api.dto.UserDTO; +import edu.harvard.iq.dataverse.util.BundleUtil; import io.restassured.RestAssured; import static io.restassured.RestAssured.given; import io.restassured.http.ContentType; @@ -515,6 +517,129 @@ public void testDeleteAuthenticatedUser() { } + @Test + public void testRegisterOidcUser() { + UserDTO userDTO = new UserDTO(); + userDTO.setUsername("testRegisterOidcUserUsername"); + userDTO.setEmailAddress("testregisteroidcuser@dataverse.com"); + userDTO.setFirstName("Firstname"); + userDTO.setLastName("Lastname"); + + // Should return error when empty token is passed + Response registerOidcUserResponse = UtilIT.registerOidcUser( + "{}", + "" + ); + registerOidcUserResponse.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("message", equalTo(BundleUtil.getStringFromBundle("users.api.errors.bearerTokenRequired"))); + + // Should return error when a required field in the User JSON is missing (username) + registerOidcUserResponse = UtilIT.registerOidcUser( + "{" + + "\"firstName\":\"YourFirstName\"," + + "\"lastName\":\"YourLastName\"," + + "\"emailAddress\":\"yourEmail@example.com\"," + + "\"affiliation\":\"YourAffiliation\"," + + "\"position\":\"YourPosition\"," + + "\"termsAccepted\":true" + + "}", + "Bearer testBearerToken" + ); + registerOidcUserResponse.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("message", equalTo("Error parsing the POSTed User json: Field 'username' is mandatory")); + + // Should return error when a required field in the User JSON is missing (firstName) + registerOidcUserResponse = UtilIT.registerOidcUser( + "{" + + "\"username\":\"yourUsername\"," + + "\"lastName\":\"YourLastName\"," + + "\"emailAddress\":\"yourEmail@example.com\"," + + "\"affiliation\":\"YourAffiliation\"," + + "\"position\":\"YourPosition\"," + + "\"termsAccepted\":true" + + "}", + "Bearer testBearerToken" + ); + registerOidcUserResponse.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("message", equalTo("Error parsing the POSTed User json: Field 'firstName' is mandatory")); + + // Should return error when a required field in the User JSON is missing (lastName) + registerOidcUserResponse = UtilIT.registerOidcUser( + "{" + + "\"username\":\"yourUsername\"," + + "\"firstName\":\"YourFirstName\"," + + "\"emailAddress\":\"yourEmail@example.com\"," + + "\"affiliation\":\"YourAffiliation\"," + + "\"position\":\"YourPosition\"," + + "\"termsAccepted\":true" + + "}", + "Bearer testBearerToken" + ); + registerOidcUserResponse.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("message", equalTo("Error parsing the POSTed User json: Field 'lastName' is mandatory")); + + // Should return error when a required field in the User JSON is missing (emailAddress) + registerOidcUserResponse = UtilIT.registerOidcUser( + "{" + + "\"username\":\"yourUsername\"," + + "\"firstName\":\"YourFirstName\"," + + "\"lastName\":\"YourLastName\"," + + "\"affiliation\":\"YourAffiliation\"," + + "\"position\":\"YourPosition\"," + + "\"termsAccepted\":true" + + "}", + "Bearer testBearerToken" + ); + registerOidcUserResponse.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("message", equalTo("Error parsing the POSTed User json: Field 'emailAddress' is mandatory")); + + // Should return error when a required field in the User JSON is missing (termsAccepted) + registerOidcUserResponse = UtilIT.registerOidcUser( + "{" + + "\"username\":\"yourUsername\"," + + "\"firstName\":\"YourFirstName\"," + + "\"lastName\":\"YourLastName\"," + + "\"emailAddress\":\"yourEmail@example.com\"," + + "\"affiliation\":\"YourAffiliation\"," + + "\"position\":\"YourPosition\"" + + "}", + "Bearer testBearerToken" + ); + registerOidcUserResponse.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("message", equalTo("Error parsing the POSTed User json: Field 'termsAccepted' is mandatory")); + + // Should return error when a malformed User JSON is sent + registerOidcUserResponse = UtilIT.registerOidcUser( + "{{{user:abcde}", + "Bearer testBearerToken" + ); + registerOidcUserResponse.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("message", equalTo("Error parsing the POSTed User json: Invalid token=CURLYOPEN at (line no=1, column no=2, offset=1). Expected tokens are: [STRING]")); + + // Should return error when User JSON is valid but the provided token is invalid + registerOidcUserResponse = UtilIT.registerOidcUser( + "{" + + "\"username\":\"yourUsername\"," + + "\"firstName\":\"YourFirstName\"," + + "\"lastName\":\"YourLastName\"," + + "\"emailAddress\":\"yourEmail@example.com\"," + + "\"affiliation\":\"YourAffiliation\"," + + "\"position\":\"YourPosition\"," + + "\"termsAccepted\":true" + + "}", + "Bearer testBearerToken" + ); + registerOidcUserResponse.prettyPrint(); + // TODO: Fix perms User :guest is not permitted to perform requested action. + } + private Response convertUserFromBcryptToSha1(long idOfBcryptUserToConvert, String password) { JsonObjectBuilder data = Json.createObjectBuilder(); data.add("builtinUserId", idOfBcryptUserToConvert); diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java index 502f1ecb0a8..e813f3a2f7b 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java @@ -1,5 +1,6 @@ package edu.harvard.iq.dataverse.api; +import edu.harvard.iq.dataverse.api.dto.UserDTO; import edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder; import io.restassured.http.ContentType; import io.restassured.path.json.JsonPath; @@ -24,6 +25,7 @@ import edu.harvard.iq.dataverse.api.datadeposit.SwordConfigurationImpl; import io.restassured.path.xml.XmlPath; import edu.harvard.iq.dataverse.mydata.MyDataFilterParams; +import jakarta.ws.rs.core.HttpHeaders; import org.apache.commons.lang3.StringUtils; import org.junit.jupiter.api.Test; import edu.harvard.iq.dataverse.settings.SettingsServiceBean; @@ -4241,4 +4243,11 @@ static Response deleteDatasetTypes(long doomed, String apiToken) { .delete("/api/datasets/datasetTypes/" + doomed); } + static Response registerOidcUser(String jsonIn, String bearerToken) { + return given() + .header(HttpHeaders.AUTHORIZATION, bearerToken) + .body(jsonIn) + .contentType(ContentType.JSON) + .post("/api/users/register"); + } } From b1901c243fd6c96e4803322379b15fb9f9fe946b Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 8 Nov 2024 18:33:03 +0000 Subject: [PATCH 22/62] Fixed: users register endpoint response body structure when there are field errors --- .../java/edu/harvard/iq/dataverse/api/AbstractApiBean.java | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java b/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java index d34eb1755b6..925ced8acd1 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java @@ -810,15 +810,11 @@ protected Response badRequest( String msg ) { } protected Response badRequest(String msg, Map fieldErrors) { - JsonObject fieldErrorsJson = Json.createObjectBuilder() - .add("fieldErrors", Json.createObjectBuilder(fieldErrors).build()) - .build(); - return Response.status(Status.BAD_REQUEST) .entity(NullSafeJsonBuilder.jsonObjectBuilder() .add("status", ApiConstants.STATUS_ERROR) .add("message", msg) - .add("fieldErrors", fieldErrorsJson) + .add("fieldErrors", Json.createObjectBuilder(fieldErrors).build()) .build() ) .type(MediaType.APPLICATION_JSON_TYPE) From fadebca251e7b4731b942e223a58551ea9d15044 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 8 Nov 2024 18:34:44 +0000 Subject: [PATCH 23/62] Added: test assertions in UsersIT for register endpoint --- .../edu/harvard/iq/dataverse/api/UsersIT.java | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java index b91281632f3..ebb8d52a9fd 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java @@ -623,7 +623,27 @@ public void testRegisterOidcUser() { .statusCode(BAD_REQUEST.getStatusCode()) .body("message", equalTo("Error parsing the POSTed User json: Invalid token=CURLYOPEN at (line no=1, column no=2, offset=1). Expected tokens are: [STRING]")); - // Should return error when User JSON is valid but the provided token is invalid + // Should return error when the provided User JSON have invalid fields + registerOidcUserResponse = UtilIT.registerOidcUser( + "{" + + "\"username\":\"dataverseAdmin\"," + + "\"firstName\":\"YourFirstName\"," + + "\"lastName\":\"YourLastName\"," + + "\"emailAddress\":\"dataverse@mailinator.com\"," + + "\"affiliation\":\"YourAffiliation\"," + + "\"position\":\"YourPosition\"," + + "\"termsAccepted\":false" + + "}", + "Bearer testBearerToken" + ); + registerOidcUserResponse.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("message", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.invalidFields"))) + .body("fieldErrors.emailAddress", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailAddressInUse"))) + .body("fieldErrors.termsAccepted", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms"))) + .body("fieldErrors.username", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.usernameInUse"))); + + // Should return error when the provided User JSON is valid but the provided Bearer token is invalid registerOidcUserResponse = UtilIT.registerOidcUser( "{" + "\"username\":\"yourUsername\"," @@ -636,8 +656,9 @@ public void testRegisterOidcUser() { + "}", "Bearer testBearerToken" ); - registerOidcUserResponse.prettyPrint(); - // TODO: Fix perms User :guest is not permitted to perform requested action. + registerOidcUserResponse.then().assertThat() + .statusCode(UNAUTHORIZED.getStatusCode()); + // TODO: Complete test assertions } private Response convertUserFromBcryptToSha1(long idOfBcryptUserToConvert, String password) { From b993ba1e72b8ad65a68e104447321e2b01ed12c8 Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 11 Nov 2024 10:44:13 +0000 Subject: [PATCH 24/62] Changed: handling more specific response messages on command PermissionException --- .../iq/dataverse/api/AbstractApiBean.java | 16 +++++-- .../exception/PermissionException.java | 46 +++++++++++-------- .../command/impl/RegisterOidcUserCommand.java | 2 +- .../edu/harvard/iq/dataverse/api/UsersIT.java | 5 +- 4 files changed, 46 insertions(+), 23 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java b/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java index 925ced8acd1..3c1074b75bb 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java @@ -629,10 +629,20 @@ protected T execCommand( Command cmd ) throws WrappedResponse { * sometimes?) doesn't have much information in it: * * "User @jsmith is not permitted to perform requested action." + * + * Update (11/11/2024): + * + * An {@code isDetailedMessageRequired} flag has been added to {@code PermissionException} to selectively return more + * specific error messages when the generic message (e.g. "User :guest is not permitted to perform requested action") + * lacks sufficient context. This approach aims to provide valuable permission-related details in cases where it + * could help users better understand their permission issues without exposing unnecessary internal information. */ - throw new WrappedResponse(error(Response.Status.UNAUTHORIZED, - "User " + cmd.getRequest().getUser().getIdentifier() + " is not permitted to perform requested action.") ); - + if (ex.isDetailedMessageRequired()) { + throw new WrappedResponse(error(Response.Status.UNAUTHORIZED, ex.getMessage())); + } else { + throw new WrappedResponse(error(Response.Status.UNAUTHORIZED, + "User " + cmd.getRequest().getUser().getIdentifier() + " is not permitted to perform requested action.")); + } } catch (InvalidFieldsCommandException ex) { throw new WrappedResponse(ex, badRequest(ex.getMessage(), ex.getFieldErrors())); } catch (CommandException ex) { diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/exception/PermissionException.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/exception/PermissionException.java index a7881fc7b6e..2ca63c9c4aa 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/exception/PermissionException.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/exception/PermissionException.java @@ -3,6 +3,7 @@ import edu.harvard.iq.dataverse.DvObject; import edu.harvard.iq.dataverse.authorization.Permission; import edu.harvard.iq.dataverse.engine.command.Command; + import java.util.Set; /** @@ -12,22 +13,31 @@ * @author michael */ public class PermissionException extends CommandException { - - private final Set required; - private final DvObject dvObject; - - public PermissionException(String message, Command failedCommand, Set required, DvObject aDvObject ) { - super(message, failedCommand); - this.required = required; - dvObject = aDvObject; - } - - public Set getRequiredPermissions() { - return required; - } - - public DvObject getDvObject() { - return dvObject; - } - + + private final Set required; + private final DvObject dvObject; + private final boolean isDetailedMessageRequired; + + public PermissionException(String message, Command failedCommand, Set required, DvObject dvObject, boolean isDetailedMessageRequired) { + super(message, failedCommand); + this.required = required; + this.dvObject = dvObject; + this.isDetailedMessageRequired = isDetailedMessageRequired; + } + + public PermissionException(String message, Command failedCommand, Set required, DvObject dvObject) { + this(message, failedCommand, required, dvObject, false); + } + + public Set getRequiredPermissions() { + return required; + } + + public DvObject getDvObject() { + return dvObject; + } + + public boolean isDetailedMessageRequired() { + return isDetailedMessageRequired; + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java index 99a2d5f6c82..1e9d48e844d 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java @@ -87,7 +87,7 @@ private void createUser(CommandContext ctxt) throws CommandException { ctxt.authentication().createAuthenticatedUser(userRecordIdentifier, userDTO.getUsername(), userInfo, true); } catch (AuthorizationException ex) { - throw new PermissionException(ex.getMessage(), this, null, null); + throw new PermissionException(ex.getMessage(), this, null, null, true); } } } diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java index ebb8d52a9fd..69d94fefa68 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java @@ -656,8 +656,11 @@ public void testRegisterOidcUser() { + "}", "Bearer testBearerToken" ); + registerOidcUserResponse.prettyPrint(); registerOidcUserResponse.then().assertThat() - .statusCode(UNAUTHORIZED.getStatusCode()); + .statusCode(UNAUTHORIZED.getStatusCode()) + .body("message", equalTo("Unauthorized bearer token.")); + // TODO: Complete test assertions } From 32f5fec57d618b876f54c31615b2d96ace3b427d Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 11 Nov 2024 12:56:17 +0000 Subject: [PATCH 25/62] Changed: test-realm.json to include new admin role in test realm necessary for IT --- conf/keycloak/test-realm.json | 672 ++++++++++++++++++++-------------- 1 file changed, 398 insertions(+), 274 deletions(-) diff --git a/conf/keycloak/test-realm.json b/conf/keycloak/test-realm.json index efe71cc5d29..2e5ed1c4d69 100644 --- a/conf/keycloak/test-realm.json +++ b/conf/keycloak/test-realm.json @@ -45,287 +45,411 @@ "quickLoginCheckMilliSeconds" : 1000, "maxDeltaTimeSeconds" : 43200, "failureFactor" : 30, - "roles" : { - "realm" : [ { - "id" : "075daee1-5ab2-44b5-adbf-fa49a3da8305", - "name" : "uma_authorization", - "description" : "${role_uma_authorization}", - "composite" : false, - "clientRole" : false, - "containerId" : "80a7e04b-a2b5-4891-a2d1-5ad4e915f983", - "attributes" : { } - }, { - "id" : "b4ff9091-ddf9-4536-b175-8cfa3e331d71", - "name" : "default-roles-test", - "description" : "${role_default-roles}", - "composite" : true, - "composites" : { - "realm" : [ "offline_access", "uma_authorization" ], - "client" : { - "account" : [ "view-profile", "manage-account" ] - } + "roles": { + "realm": [ + { + "id": "075daee1-5ab2-44b5-adbf-fa49a3da8305", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "80a7e04b-a2b5-4891-a2d1-5ad4e915f983", + "attributes": {} }, - "clientRole" : false, - "containerId" : "80a7e04b-a2b5-4891-a2d1-5ad4e915f983", - "attributes" : { } - }, { - "id" : "e6d31555-6be6-4dee-bc6a-40a53108e4c2", - "name" : "offline_access", - "description" : "${role_offline-access}", - "composite" : false, - "clientRole" : false, - "containerId" : "80a7e04b-a2b5-4891-a2d1-5ad4e915f983", - "attributes" : { } - } ], - "client" : { - "realm-management" : [ { - "id" : "1955bd12-5f86-4a74-b130-d68a8ef6f0ee", - "name" : "impersonation", - "description" : "${role_impersonation}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "1109c350-9ab1-426c-9876-ef67d4310f35", - "name" : "view-authorization", - "description" : "${role_view-authorization}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "980c3fd3-1ae3-4b8f-9a00-d764c939035f", - "name" : "query-users", - "description" : "${role_query-users}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "5363e601-0f9d-4633-a8c8-28cb0f859b7b", - "name" : "query-groups", - "description" : "${role_query-groups}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "59aa7992-ad78-48db-868a-25d6e1d7db50", - "name" : "realm-admin", - "description" : "${role_realm-admin}", - "composite" : true, - "composites" : { - "client" : { - "realm-management" : [ "impersonation", "view-authorization", "query-users", "query-groups", "manage-clients", "manage-realm", "view-identity-providers", "query-realms", "manage-authorization", "manage-identity-providers", "manage-users", "view-users", "view-realm", "create-client", "view-clients", "manage-events", "query-clients", "view-events" ] + { + "id": "b4ff9091-ddf9-4536-b175-8cfa3e331d71", + "name": "default-roles-test", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": [ + "offline_access", + "uma_authorization" + ], + "client": { + "account": [ + "view-profile", + "manage-account" + ] } }, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "112f53c2-897d-4c01-81db-b8dc10c5b995", - "name" : "manage-clients", - "description" : "${role_manage-clients}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "c7f57bbd-ef32-4a64-9888-7b8abd90777a", - "name" : "manage-realm", - "description" : "${role_manage-realm}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "8885dac8-0af3-45af-94ce-eff5e801bb80", - "name" : "view-identity-providers", - "description" : "${role_view-identity-providers}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "2673346c-b0ef-4e01-8a90-be03866093af", - "name" : "manage-authorization", - "description" : "${role_manage-authorization}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "b7182885-9e57-445f-8dae-17c16eb31b5d", - "name" : "manage-identity-providers", - "description" : "${role_manage-identity-providers}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "ba7bfe0c-cb07-4a47-b92c-b8132b57e181", - "name" : "manage-users", - "description" : "${role_manage-users}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "13a8f0fc-647d-4bfe-b525-73956898e550", - "name" : "query-realms", - "description" : "${role_query-realms}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "ef4c57dc-78c2-4f9a-8d2b-0e97d46fc842", - "name" : "view-realm", - "description" : "${role_view-realm}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "2875da34-006c-4b7f-bfc8-9ae8e46af3a2", - "name" : "view-users", - "description" : "${role_view-users}", - "composite" : true, - "composites" : { - "client" : { - "realm-management" : [ "query-users", "query-groups" ] + "clientRole": false, + "containerId": "80a7e04b-a2b5-4891-a2d1-5ad4e915f983", + "attributes": {} + }, + { + "id": "131ff85b-0c25-491b-8e13-dde779ec0854", + "name": "admin", + "description": "", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "impersonation", + "view-authorization", + "query-users", + "manage-realm", + "view-identity-providers", + "manage-authorization", + "view-clients", + "manage-events", + "query-clients", + "view-events", + "query-groups", + "realm-admin", + "manage-clients", + "query-realms", + "manage-identity-providers", + "manage-users", + "view-users", + "view-realm", + "create-client" + ], + "broker": [ + "read-token" + ], + "account": [ + "delete-account", + "manage-consent", + "view-consent", + "view-applications", + "view-groups", + "manage-account-links", + "view-profile", + "manage-account" + ] } }, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "c8c8f7dc-876b-4263-806f-3329f7cd5fd3", - "name" : "create-client", - "description" : "${role_create-client}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "21b84f90-5a9a-4845-a7ba-bbd98ac0fcc4", - "name" : "view-clients", - "description" : "${role_view-clients}", - "composite" : true, - "composites" : { - "client" : { - "realm-management" : [ "query-clients" ] - } + "clientRole": false, + "containerId": "80a7e04b-a2b5-4891-a2d1-5ad4e915f983", + "attributes": {} + }, + { + "id": "e6d31555-6be6-4dee-bc6a-40a53108e4c2", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "80a7e04b-a2b5-4891-a2d1-5ad4e915f983", + "attributes": {} + } + ], + "client": { + "realm-management": [ + { + "id": "1955bd12-5f86-4a74-b130-d68a8ef6f0ee", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} }, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "6fd64c94-d663-4501-ad77-0dcf8887d434", - "name" : "manage-events", - "description" : "${role_manage-events}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "b321927a-023c-4d2a-99ad-24baf7ff6d83", - "name" : "query-clients", - "description" : "${role_query-clients}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "2fc21160-78de-457b-8594-e5c76cde1d5e", - "name" : "view-events", - "description" : "${role_view-events}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - } ], - "test" : [ ], - "security-admin-console" : [ ], - "admin-cli" : [ ], - "account-console" : [ ], - "broker" : [ { - "id" : "07ee59b5-dca6-48fb-83d4-2994ef02850e", - "name" : "read-token", - "description" : "${role_read-token}", - "composite" : false, - "clientRole" : true, - "containerId" : "b57d62bb-77ff-42bd-b8ff-381c7288f327", - "attributes" : { } - } ], - "account" : [ { - "id" : "17d2f811-7bdf-4c73-83b4-1037001797b8", - "name" : "view-applications", - "description" : "${role_view-applications}", - "composite" : false, - "clientRole" : true, - "containerId" : "77f8127a-261e-4cd8-a77d-b74a389f7fd4", - "attributes" : { } - }, { - "id" : "d1ff44f9-419e-42fd-98e8-1add1169a972", - "name" : "delete-account", - "description" : "${role_delete-account}", - "composite" : false, - "clientRole" : true, - "containerId" : "77f8127a-261e-4cd8-a77d-b74a389f7fd4", - "attributes" : { } - }, { - "id" : "14c23a18-ae2d-43c9-b0c0-aaf6e0c7f5b0", - "name" : "manage-account-links", - "description" : "${role_manage-account-links}", - "composite" : false, - "clientRole" : true, - "containerId" : "77f8127a-261e-4cd8-a77d-b74a389f7fd4", - "attributes" : { } - }, { - "id" : "6fbe58af-d2fe-4d66-95fe-a2e8a818cb55", - "name" : "view-profile", - "description" : "${role_view-profile}", - "composite" : false, - "clientRole" : true, - "containerId" : "77f8127a-261e-4cd8-a77d-b74a389f7fd4", - "attributes" : { } - }, { - "id" : "bdfd02bc-6f6a-47d2-82bc-0ca52d78ff48", - "name" : "manage-consent", - "description" : "${role_manage-consent}", - "composite" : true, - "composites" : { - "client" : { - "account" : [ "view-consent" ] - } + { + "id": "1109c350-9ab1-426c-9876-ef67d4310f35", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} }, - "clientRole" : true, - "containerId" : "77f8127a-261e-4cd8-a77d-b74a389f7fd4", - "attributes" : { } - }, { - "id" : "782f3b0c-a17b-4a87-988b-1a711401f3b0", - "name" : "manage-account", - "description" : "${role_manage-account}", - "composite" : true, - "composites" : { - "client" : { - "account" : [ "manage-account-links" ] - } + { + "id": "980c3fd3-1ae3-4b8f-9a00-d764c939035f", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "5363e601-0f9d-4633-a8c8-28cb0f859b7b", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "59aa7992-ad78-48db-868a-25d6e1d7db50", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "impersonation", + "view-authorization", + "query-users", + "query-groups", + "manage-clients", + "manage-realm", + "view-identity-providers", + "query-realms", + "manage-authorization", + "manage-identity-providers", + "manage-users", + "view-users", + "view-realm", + "create-client", + "view-clients", + "manage-events", + "query-clients", + "view-events" + ] + } + }, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "112f53c2-897d-4c01-81db-b8dc10c5b995", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "c7f57bbd-ef32-4a64-9888-7b8abd90777a", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "8885dac8-0af3-45af-94ce-eff5e801bb80", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "2673346c-b0ef-4e01-8a90-be03866093af", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "b7182885-9e57-445f-8dae-17c16eb31b5d", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "ba7bfe0c-cb07-4a47-b92c-b8132b57e181", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} }, - "clientRole" : true, - "containerId" : "77f8127a-261e-4cd8-a77d-b74a389f7fd4", - "attributes" : { } - }, { - "id" : "8a3bfe15-66d9-4f3d-83ac-801d682d42b0", - "name" : "view-consent", - "description" : "${role_view-consent}", - "composite" : false, - "clientRole" : true, - "containerId" : "77f8127a-261e-4cd8-a77d-b74a389f7fd4", - "attributes" : { } - } ] + { + "id": "13a8f0fc-647d-4bfe-b525-73956898e550", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "ef4c57dc-78c2-4f9a-8d2b-0e97d46fc842", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "2875da34-006c-4b7f-bfc8-9ae8e46af3a2", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-users", + "query-groups" + ] + } + }, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "c8c8f7dc-876b-4263-806f-3329f7cd5fd3", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "21b84f90-5a9a-4845-a7ba-bbd98ac0fcc4", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-clients" + ] + } + }, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "6fd64c94-d663-4501-ad77-0dcf8887d434", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "b321927a-023c-4d2a-99ad-24baf7ff6d83", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "2fc21160-78de-457b-8594-e5c76cde1d5e", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + } + ], + "test": [], + "security-admin-console": [], + "admin-cli": [], + "account-console": [], + "broker": [ + { + "id": "07ee59b5-dca6-48fb-83d4-2994ef02850e", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "b57d62bb-77ff-42bd-b8ff-381c7288f327", + "attributes": {} + } + ], + "account": [ + { + "id": "17d2f811-7bdf-4c73-83b4-1037001797b8", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "77f8127a-261e-4cd8-a77d-b74a389f7fd4", + "attributes": {} + }, + { + "id": "f5918d56-bd4d-4035-8fa7-8622075ed690", + "name": "view-groups", + "description": "${role_view-groups}", + "composite": false, + "clientRole": true, + "containerId": "77f8127a-261e-4cd8-a77d-b74a389f7fd4", + "attributes": {} + }, + { + "id": "d1ff44f9-419e-42fd-98e8-1add1169a972", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "77f8127a-261e-4cd8-a77d-b74a389f7fd4", + "attributes": {} + }, + { + "id": "14c23a18-ae2d-43c9-b0c0-aaf6e0c7f5b0", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "77f8127a-261e-4cd8-a77d-b74a389f7fd4", + "attributes": {} + }, + { + "id": "6fbe58af-d2fe-4d66-95fe-a2e8a818cb55", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "77f8127a-261e-4cd8-a77d-b74a389f7fd4", + "attributes": {} + }, + { + "id": "bdfd02bc-6f6a-47d2-82bc-0ca52d78ff48", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": [ + "view-consent" + ] + } + }, + "clientRole": true, + "containerId": "77f8127a-261e-4cd8-a77d-b74a389f7fd4", + "attributes": {} + }, + { + "id": "782f3b0c-a17b-4a87-988b-1a711401f3b0", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": [ + "manage-account-links" + ] + } + }, + "clientRole": true, + "containerId": "77f8127a-261e-4cd8-a77d-b74a389f7fd4", + "attributes": {} + }, + { + "id": "8a3bfe15-66d9-4f3d-83ac-801d682d42b0", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "77f8127a-261e-4cd8-a77d-b74a389f7fd4", + "attributes": {} + } + ] } }, "groups" : [ { @@ -409,7 +533,7 @@ } ], "disableableCredentialTypes" : [ ], "requiredActions" : [ ], - "realmRoles" : [ "default-roles-test" ], + "realmRoles" : [ "default-roles-test", "admin" ], "notBefore" : 0, "groups" : [ "/admins" ] }, { From c0c6704899b382adfab93dd67d1304b601e3c1d7 Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 11 Nov 2024 12:56:37 +0000 Subject: [PATCH 26/62] Added: new test cases for registerOidcUser --- .../edu/harvard/iq/dataverse/api/UsersIT.java | 89 ++++++++++++++----- .../edu/harvard/iq/dataverse/api/UtilIT.java | 47 +++++++++- 2 files changed, 114 insertions(+), 22 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java index 69d94fefa68..43f18398cc2 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java @@ -15,11 +15,9 @@ import java.util.UUID; import jakarta.json.Json; import jakarta.json.JsonObjectBuilder; -import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST; -import static jakarta.ws.rs.core.Response.Status.CREATED; -import static jakarta.ws.rs.core.Response.Status.NOT_FOUND; -import static jakarta.ws.rs.core.Response.Status.OK; -import static jakarta.ws.rs.core.Response.Status.UNAUTHORIZED; + +import static jakarta.ws.rs.core.Response.Status.*; +import static org.hamcrest.Matchers.notNullValue; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.equalTo; @@ -28,6 +26,7 @@ import org.hamcrest.CoreMatchers; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; public class UsersIT { @@ -518,12 +517,33 @@ public void testDeleteAuthenticatedUser() { } @Test + // This test is disabled because it is only compatible with the containerized development environment and would cause the Jenkins job to fail. + @Disabled public void testRegisterOidcUser() { - UserDTO userDTO = new UserDTO(); - userDTO.setUsername("testRegisterOidcUserUsername"); - userDTO.setEmailAddress("testregisteroidcuser@dataverse.com"); - userDTO.setFirstName("Firstname"); - userDTO.setLastName("Lastname"); + // Set Up - Get the admin access token from the OIDC provider + Response adminOidcLoginResponse = UtilIT.performKeycloakROPCLogin("admin", "admin"); + adminOidcLoginResponse.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("access_token", notNullValue()); + String adminOidcAccessToken = adminOidcLoginResponse.jsonPath().getString("access_token"); + + // Set Up - Create random user in the OIDC provider + String randomUsername = UUID.randomUUID().toString().substring(0, 8); + String newKeycloakUserJson = "{" + + "\"username\":\"" + randomUsername + "\"," + + "\"enabled\":true," + + "\"credentials\":[" + + " {" + + " \"type\":\"password\"," + + " \"value\":\"password\"," + + " \"temporary\":false" + + " }" + + "]" + + "}"; + Response createKeycloakOidcUserResponse = UtilIT.createKeycloakUser(adminOidcAccessToken, newKeycloakUserJson); + createKeycloakOidcUserResponse.then().assertThat().statusCode(CREATED.getStatusCode()); + Response newUserOidcLoginResponse = UtilIT.performKeycloakROPCLogin(randomUsername, "password"); + String newUserOidcAccessToken = newUserOidcLoginResponse.jsonPath().getString("access_token"); // Should return error when empty token is passed Response registerOidcUserResponse = UtilIT.registerOidcUser( @@ -644,24 +664,51 @@ public void testRegisterOidcUser() { .body("fieldErrors.username", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.usernameInUse"))); // Should return error when the provided User JSON is valid but the provided Bearer token is invalid + randomUsername = UUID.randomUUID().toString().substring(0, 8); + String randomEmail = randomUsername + "@dataverse.com"; + String validUserJson = "{" + + "\"username\":\"" + randomUsername + "\"," + + "\"firstName\":\"YourFirstName\"," + + "\"lastName\":\"YourLastName\"," + + "\"emailAddress\":\"" + randomEmail + "\"," + + "\"affiliation\":\"YourAffiliation\"," + + "\"position\":\"YourPosition\"," + + "\"termsAccepted\":true" + + "}"; registerOidcUserResponse = UtilIT.registerOidcUser( - "{" - + "\"username\":\"yourUsername\"," - + "\"firstName\":\"YourFirstName\"," - + "\"lastName\":\"YourLastName\"," - + "\"emailAddress\":\"yourEmail@example.com\"," - + "\"affiliation\":\"YourAffiliation\"," - + "\"position\":\"YourPosition\"," - + "\"termsAccepted\":true" - + "}", + validUserJson, "Bearer testBearerToken" ); - registerOidcUserResponse.prettyPrint(); registerOidcUserResponse.then().assertThat() .statusCode(UNAUTHORIZED.getStatusCode()) .body("message", equalTo("Unauthorized bearer token.")); - // TODO: Complete test assertions + // Should register user when the provided User JSON is valid and the provided Bearer token is valid + registerOidcUserResponse = UtilIT.registerOidcUser( + validUserJson, + "Bearer " + newUserOidcAccessToken + ); + registerOidcUserResponse.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.message", equalTo("User registered.")); + + // Should return error when attempting to re-register with the same Bearer token but different User data + String newUserJson = "{" + + "\"username\":\"newUsername\"," + + "\"firstName\":\"NewFirstName\"," + + "\"lastName\":\"NewLastName\"," + + "\"emailAddress\":\"newEmail@example.com\"," + + "\"affiliation\":\"YourAffiliation\"," + + "\"position\":\"YourPosition\"," + + "\"termsAccepted\":true" + + "}"; + registerOidcUserResponse = UtilIT.registerOidcUser( + newUserJson, + "Bearer " + newUserOidcAccessToken + ); + registerOidcUserResponse.then().assertThat() + .statusCode(FORBIDDEN.getStatusCode()) + .body("message", equalTo("User is already registered with this token.")); } private Response convertUserFromBcryptToSha1(long idOfBcryptUserToConvert, String password) { diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java index e813f3a2f7b..5cf2059427d 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java @@ -1,6 +1,5 @@ package edu.harvard.iq.dataverse.api; -import edu.harvard.iq.dataverse.api.dto.UserDTO; import edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder; import io.restassured.http.ContentType; import io.restassured.path.json.JsonPath; @@ -4250,4 +4249,50 @@ static Response registerOidcUser(String jsonIn, String bearerToken) { .contentType(ContentType.JSON) .post("/api/users/register"); } + + /** + * Creates a new user in the development Keycloak instance. + *

This method is specifically designed for use in the containerized Keycloak development + * environment. The configured Keycloak instance must be accessible at the specified URL. + * The method sends a request to the Keycloak Admin API to create a new user in the given realm. + * + *

Refer to the {@code testRegisterOidc()} method in the {@code UsersIT} class for an example + * of this method in action. + * + * @param bearerToken The Bearer token used for authenticating the request to the Keycloak Admin API. + * @param userJson The JSON representation of the user to be created. + * @return A {@link Response} containing the result of the user creation request. + */ + static Response createKeycloakUser(String bearerToken, String userJson) { + return given() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + bearerToken) + .body(userJson) + .post("http://keycloak.mydomain.com:8090/admin/realms/test/users"); + } + + /** + * Performs an OIDC login in the development Keycloak instance using the Resource Owner Password Credentials (ROPC) + * grant type to retrieve authentication tokens from a Keycloak instance. + * + *

This method is specifically designed for use in the containerized Keycloak development + * environment. The configured Keycloak instance must be accessible at the specified URL. + * + *

Refer to the {@code testRegisterOidc()} method in the {@code UsersIT} class for an example + * of this method in action. + * + * @return A {@link Response} containing authentication tokens, including access and refresh tokens, + * if the login is successful. + */ + static Response performKeycloakROPCLogin(String username, String password) { + return given() + .contentType(ContentType.URLENC) + .formParam("client_id", "test") + .formParam("client_secret", "94XHrfNRwXsjqTqApRrwWmhDLDHpIYV8") + .formParam("username", username) + .formParam("password", password) + .formParam("grant_type", "password") + .formParam("scope", "openid") + .post("http://keycloak.mydomain.com:8090/realms/test/protocol/openid-connect/token"); + } } From a064a7b1705252b396c2aa0759599b3726b01af4 Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 11 Nov 2024 12:57:34 +0000 Subject: [PATCH 27/62] Removed: unused imports --- src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java index 43f18398cc2..ecf0e901943 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java @@ -1,18 +1,20 @@ package edu.harvard.iq.dataverse.api; -import edu.harvard.iq.dataverse.api.dto.UserDTO; import edu.harvard.iq.dataverse.util.BundleUtil; import io.restassured.RestAssured; + import static io.restassured.RestAssured.given; + import io.restassured.http.ContentType; import io.restassured.path.json.JsonPath; import io.restassured.response.Response; import edu.harvard.iq.dataverse.authorization.DataverseRole; import edu.harvard.iq.dataverse.settings.SettingsServiceBean; -import java.util.ArrayList; + import java.util.Arrays; import java.util.List; import java.util.UUID; + import jakarta.json.Json; import jakarta.json.JsonObjectBuilder; @@ -21,7 +23,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.Matchers.contains; import static org.junit.jupiter.api.Assertions.assertTrue; import org.hamcrest.CoreMatchers; From 4bc58f6a9188213b394de8fd8bbe0b207fe458e7 Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 11 Nov 2024 13:24:03 +0000 Subject: [PATCH 28/62] Fixed: OIDCAuthenticationProviderFactoryIT --- .../OIDCAuthenticationProviderFactoryIT.java | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthenticationProviderFactoryIT.java b/src/test/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthenticationProviderFactoryIT.java index ee6823ef98a..839781b6b3b 100644 --- a/src/test/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthenticationProviderFactoryIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthenticationProviderFactoryIT.java @@ -7,7 +7,6 @@ import edu.harvard.iq.dataverse.api.auth.BearerTokenAuthMechanism; import edu.harvard.iq.dataverse.api.auth.doubles.BearerTokenKeyContainerRequestTestFake; import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; -import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2Exception; import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2UserRecord; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; @@ -38,16 +37,12 @@ import java.util.Map; import java.util.Optional; -import java.util.Set; import java.util.regex.Pattern; import java.util.stream.Collectors; import static edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthenticationProviderFactoryIT.clientId; import static edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthenticationProviderFactoryIT.clientSecret; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assumptions.assumeFalse; import static org.junit.jupiter.api.Assumptions.assumeTrue; import static org.mockito.Mockito.when; @@ -143,7 +138,7 @@ void testCreateProvider() throws Exception { /** * This test covers using an OIDC provider as authorization party when accessing the Dataverse API with a - * Bearer Token. See {@link BearerTokenAuthMechanism}. It needs to mock the auth services to avoid adding + * Bearer Token. See {@link BearerTokenAuthMechanism}. It needs to mock the auth service to avoid adding * more dependencies. */ @Test @@ -158,19 +153,15 @@ void testApiBearerAuth() throws Exception { String accessToken = getBearerTokenViaKeycloakAdminClient(); assumeFalse(accessToken == null); - OIDCAuthProvider oidcAuthProvider = getProvider(); // This will also receive the details from the remote Keycloak in the container - UserRecordIdentifier identifier = oidcAuthProvider.getUserIdentifier(new BearerAccessToken(accessToken)).get(); String token = "Bearer " + accessToken; BearerTokenKeyContainerRequestTestFake request = new BearerTokenKeyContainerRequestTestFake(token); AuthenticatedUser user = new MockAuthenticatedUser(); // setup mocks (we don't want or need a database here) - when(authService.getAuthenticationProviderIdsOfType(OIDCAuthProvider.class)).thenReturn(Set.of(oidcAuthProvider.getId())); - when(authService.getAuthenticationProvider(oidcAuthProvider.getId())).thenReturn(oidcAuthProvider); - when(authService.lookupUser(identifier)).thenReturn(user); + when(authService.lookupUserByOidcBearerToken(token)).thenReturn(user); when(userService.updateLastApiUseTime(user)).thenReturn(user); - + // when (let's do this again, but now with the actual subject under test!) User lookedUpUser = bearerTokenAuthMechanism.findUserFromRequest(request); From 4536f91774ebb769e6e9487f460c64f415cce4a8 Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 11 Nov 2024 16:57:42 +0000 Subject: [PATCH 29/62] Added: release notes for #10959 --- doc/release-notes/10959-bearer-token-user-registration.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/release-notes/10959-bearer-token-user-registration.md diff --git a/doc/release-notes/10959-bearer-token-user-registration.md b/doc/release-notes/10959-bearer-token-user-registration.md new file mode 100644 index 00000000000..4e34b1cbd17 --- /dev/null +++ b/doc/release-notes/10959-bearer-token-user-registration.md @@ -0,0 +1 @@ +The functionality of the OIDC Bearer token API authentication (available through a feature flag) has been extended to allow the registration of new users in Dataverse when there is no user account associated with the bearer token. Specifically, a new endpoint (users/register) has been implemented, to which the bearer token and user information are sent, allowing the identity provider user to be linked to a Dataverse account. \ No newline at end of file From 99ce9400018dbc29c078a7ea5cad701a986e8531 Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 11 Nov 2024 17:01:14 +0000 Subject: [PATCH 30/62] Changed: release notes tweaks --- doc/release-notes/10959-bearer-token-user-registration.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/doc/release-notes/10959-bearer-token-user-registration.md b/doc/release-notes/10959-bearer-token-user-registration.md index 4e34b1cbd17..329db550cc9 100644 --- a/doc/release-notes/10959-bearer-token-user-registration.md +++ b/doc/release-notes/10959-bearer-token-user-registration.md @@ -1 +1,5 @@ -The functionality of the OIDC Bearer token API authentication (available through a feature flag) has been extended to allow the registration of new users in Dataverse when there is no user account associated with the bearer token. Specifically, a new endpoint (users/register) has been implemented, to which the bearer token and user information are sent, allowing the identity provider user to be linked to a Dataverse account. \ No newline at end of file +The OIDC Bearer token API authentication feature (available through a feature flag) has been extended to allow the registration of new users in Dataverse when there is no user account associated with the bearer token. + +Specifically, a new endpoint (users/register) has been implemented, to which the bearer token and new user account information are sent, allowing the identity provider user to be linked to a Dataverse account. + +In this way, the user will be recognized in future requests using the bearer token in the BearerTokenAuthMechanism. From 386b6acee2d6b8267463f57e5523abdaf84ef7cb Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 13 Nov 2024 12:13:34 +0000 Subject: [PATCH 31/62] Added: new OidcUserInfo object for encapsulating both User record identifier and claims --- .../AuthenticationServiceBean.java | 29 +++++++++------- .../dataverse/authorization/OidcUserInfo.java | 33 ++++++++++++++++++ .../oauth2/oidc/OIDCAuthProvider.java | 2 +- .../command/impl/RegisterOidcUserCommand.java | 4 ++- .../impl/RegisterOidcUserCommandTest.java | 34 +++++++++++-------- 5 files changed, 73 insertions(+), 29 deletions(-) create mode 100644 src/main/java/edu/harvard/iq/dataverse/authorization/OidcUserInfo.java diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java index 811a46730ee..5124cb0d549 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -2,6 +2,7 @@ import com.nimbusds.oauth2.sdk.ParseException; import com.nimbusds.oauth2.sdk.token.BearerAccessToken; +import com.nimbusds.openid.connect.sdk.claims.UserInfo; import edu.harvard.iq.dataverse.DatasetVersionServiceBean; import edu.harvard.iq.dataverse.DvObjectServiceBean; import edu.harvard.iq.dataverse.GuestbookResponseServiceBean; @@ -9,6 +10,7 @@ import edu.harvard.iq.dataverse.UserNotificationServiceBean; import edu.harvard.iq.dataverse.UserServiceBean; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; +import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2Exception; import edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthProvider; import edu.harvard.iq.dataverse.search.IndexServiceBean; import edu.harvard.iq.dataverse.actionlogging.ActionLogRecord; @@ -985,18 +987,18 @@ public ApiToken getValidApiTokenForUser(User user) { public AuthenticatedUser lookupUserByOidcBearerToken(String bearerToken) throws AuthorizationException { // TODO: Get the identifier from an invalidating cache to avoid lookup bursts of the same token. // Tokens in the cache should be removed after some (configurable) time. - UserRecordIdentifier userInfo = verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); - return lookupUser(userInfo); + OidcUserInfo oidcUserInfo = verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); + return lookupUser(oidcUserInfo.getUserRecordIdentifier()); } /** - * Verifies the given OIDC bearer token and retrieves the corresponding user's identifier. + * Verifies the given OIDC bearer token and retrieves the corresponding OIDC user info. * * @param bearerToken The OIDC bearer token. - * @return A {@link UserRecordIdentifier} representing the user associated with the valid token. + * @return An {@link OidcUserInfo} containing the user's identifier and user info. * @throws AuthorizationException If the token is invalid or if no OIDC providers are available. */ - public UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String bearerToken) throws AuthorizationException { + public OidcUserInfo verifyOidcBearerTokenAndGetUserIdentifier(String bearerToken) throws AuthorizationException { try { BearerAccessToken accessToken = BearerAccessToken.parse(bearerToken); List providers = getAvailableOidcProviders(); @@ -1010,15 +1012,16 @@ public UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String bea // Attempt to validate the token with each configured OIDC provider. for (OIDCAuthProvider provider : providers) { try { - Optional userInfo = provider.getUserIdentifier(accessToken); - if (userInfo.isPresent()) { - logger.log(Level.FINE, "Bearer token detected, provider {0} confirmed validity and provided identifier", provider.getId()); - return userInfo.get(); + // Retrieve both user identifier and user info + Optional userRecordIdentifier = provider.getUserIdentifier(accessToken); + Optional userInfo = provider.getUserInfo(accessToken); + + // If either is present, return the result + if (userRecordIdentifier.isPresent() || userInfo.isPresent()) { + logger.log(Level.FINE, "Bearer token detected, provider {0} confirmed validity and provided user info", provider.getId()); + return new OidcUserInfo(userRecordIdentifier.get(), userInfo.get()); } - } catch (IOException e) { - // TODO: Just logging this is not sufficient - if there is an IO error with the one provider - // which would have validated successfully, this is not the users fault. We need to - // take note and refer to that later when occurred. + } catch (IOException | OAuth2Exception e) { logger.log(Level.FINE, "Bearer token detected, provider " + provider.getId() + " indicates an invalid Token, skipping", e); } } diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/OidcUserInfo.java b/src/main/java/edu/harvard/iq/dataverse/authorization/OidcUserInfo.java new file mode 100644 index 00000000000..c89ea354172 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/OidcUserInfo.java @@ -0,0 +1,33 @@ +package edu.harvard.iq.dataverse.authorization; + +import com.nimbusds.openid.connect.sdk.claims.UserInfo; + +/** + * Encapsulates both the user's identifier ({@link UserRecordIdentifier}) and the user's claims information + * ({@link UserInfo}) retrieved from an OIDC (OpenID Connect) bearer token. + *

+ * This class serves as a container for both the {@link UserRecordIdentifier}, which uniquely identifies + * the user within the system, and the {@link UserInfo}, which holds the user's claims data provided by + * an OIDC provider. It simplifies the management of these related pieces of user data when handling + * OIDC token validation and authorization processes. + * + * @see UserRecordIdentifier + * @see UserInfo + */ +public class OidcUserInfo { + private final UserRecordIdentifier userRecordIdentifier; + private final UserInfo userClaimsInfo; + + public OidcUserInfo(UserRecordIdentifier userRecordIdentifier, UserInfo userClaimsInfo) { + this.userRecordIdentifier = userRecordIdentifier; + this.userClaimsInfo = userClaimsInfo; + } + + public UserRecordIdentifier getUserRecordIdentifier() { + return userRecordIdentifier; + } + + public UserInfo getUserClaimsInfo() { + return userClaimsInfo; + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java index 5eb2b391eb7..675e1696844 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java @@ -291,7 +291,7 @@ Optional getAccessToken(AuthorizationGrant grant) throws IOEx * Retrieve User Info from provider. Encapsulate for testing. * @param accessToken The access token to enable reading data from userinfo endpoint */ - Optional getUserInfo(BearerAccessToken accessToken) throws IOException, OAuth2Exception { + public Optional getUserInfo(BearerAccessToken accessToken) throws IOException, OAuth2Exception { // Retrieve data HTTPResponse response = new UserInfoRequest(this.idpMetadata.getUserInfoEndpointURI(), accessToken) .toHTTPRequest() diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java index 1e9d48e844d..ff059e71ec6 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java @@ -3,6 +3,7 @@ import edu.harvard.iq.dataverse.DvObject; import edu.harvard.iq.dataverse.api.dto.UserDTO; import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; +import edu.harvard.iq.dataverse.authorization.OidcUserInfo; import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; import edu.harvard.iq.dataverse.engine.command.*; @@ -70,7 +71,8 @@ private boolean isUsernameInUse(CommandContext ctxt, String username) { private void createUser(CommandContext ctxt) throws CommandException { try { - UserRecordIdentifier userRecordIdentifier = ctxt.authentication().verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); + OidcUserInfo oidcUserInfo = ctxt.authentication().verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); + UserRecordIdentifier userRecordIdentifier = oidcUserInfo.getUserRecordIdentifier(); if (ctxt.authentication().lookupUser(userRecordIdentifier) != null) { throw new IllegalCommandException(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userAlreadyRegisteredWithToken"), this); diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommandTest.java index bfc693cc308..845ad8c3ed9 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommandTest.java @@ -1,8 +1,10 @@ package edu.harvard.iq.dataverse.engine.command.impl; +import com.nimbusds.openid.connect.sdk.claims.UserInfo; import edu.harvard.iq.dataverse.api.dto.UserDTO; import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; +import edu.harvard.iq.dataverse.authorization.OidcUserInfo; import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; @@ -16,7 +18,6 @@ import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import static edu.harvard.iq.dataverse.mocks.MocksFactory.makeRequest; @@ -39,10 +40,21 @@ class RegisterOidcUserCommandTest { @InjectMocks private RegisterOidcUserCommand sut; + private UserRecordIdentifier userRecordIdentifierMock; + private UserInfo userInfoMock; + private OidcUserInfo oidcUserInfoMock; + private AuthenticatedUser existingTestUser; + @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); setUpDefaultUserDTO(); + + userRecordIdentifierMock = mock(UserRecordIdentifier.class); + userInfoMock = mock(UserInfo.class); + oidcUserInfoMock = new OidcUserInfo(userRecordIdentifierMock, userInfoMock); + existingTestUser = new AuthenticatedUser(); + when(context.authentication()).thenReturn(authServiceMock); sut = new RegisterOidcUserCommand(makeRequest(), TEST_BEARER_TOKEN, userDTO); } @@ -75,9 +87,8 @@ public void execute_unacceptedTerms_availableEmailAndUsername() { @Test public void execute_acceptedTerms_availableEmailAndUsername() { - AuthenticatedUser existingUser = new AuthenticatedUser(); - when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(existingUser); - when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(existingUser); + when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(existingTestUser); + when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(existingTestUser); assertThatThrownBy(() -> sut.execute(context)) .isInstanceOf(InvalidFieldsCommandException.class) @@ -100,29 +111,25 @@ void execute_throwsPermissionException_onAuthorizationException() throws Authori .isInstanceOf(PermissionException.class) .hasMessageContaining(testAuthorizationExceptionMessage); - Mockito.verify(context.authentication(), times(1)) - .verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN); + verify(context.authentication(), times(1)).verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN); } @Test void execute_throwsIllegalCommandException_ifUserAlreadyRegisteredWithToken() throws AuthorizationException { - UserRecordIdentifier userRecordIdentifierMock = Mockito.mock(UserRecordIdentifier.class); when(context.authentication().verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)) - .thenReturn(userRecordIdentifierMock); + .thenReturn(oidcUserInfoMock); when(context.authentication().lookupUser(userRecordIdentifierMock)).thenReturn(new AuthenticatedUser()); assertThatThrownBy(() -> sut.execute(context)) .isInstanceOf(IllegalCommandException.class) .hasMessageContaining(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userAlreadyRegisteredWithToken")); - Mockito.verify(context.authentication(), times(1)) - .lookupUser(userRecordIdentifierMock); + verify(context.authentication(), times(1)).lookupUser(userRecordIdentifierMock); } @Test void execute_happyPath_withoutAffiliationAndPosition() throws AuthorizationException, CommandException { - UserRecordIdentifier userRecordIdentifierMock = mock(UserRecordIdentifier.class); - when(authServiceMock.verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(userRecordIdentifierMock); + when(authServiceMock.verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(oidcUserInfoMock); sut.execute(context); @@ -145,8 +152,7 @@ void execute_happyPath_withAffiliationAndPosition() throws AuthorizationExceptio userDTO.setPosition("test position"); userDTO.setAffiliation("test affiliation"); - UserRecordIdentifier userRecordIdentifierMock = mock(UserRecordIdentifier.class); - when(authServiceMock.verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(userRecordIdentifierMock); + when(authServiceMock.verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(oidcUserInfoMock); sut.execute(context); From b5d40adb7f3c6782e9d7042e76e332dffc8bf263 Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 13 Nov 2024 12:21:20 +0000 Subject: [PATCH 32/62] Refactor: renamed classes and methods from 'Oidc/oidc' to 'OIDC' to be consistent with the standard --- .../edu/harvard/iq/dataverse/api/Users.java | 4 +- .../api/auth/BearerTokenAuthMechanism.java | 2 +- .../AuthenticationServiceBean.java | 10 ++--- .../{OidcUserInfo.java => OIDCUserInfo.java} | 4 +- ...mand.java => RegisterOIDCUserCommand.java} | 8 ++-- .../edu/harvard/iq/dataverse/api/UsersIT.java | 2 +- .../auth/BearerTokenAuthMechanismTest.java | 6 +-- .../AuthenticationServiceBeanTest.java | 44 ++++++++++--------- .../OIDCAuthenticationProviderFactoryIT.java | 2 +- ....java => RegisterOIDCUserCommandTest.java} | 24 +++++----- 10 files changed, 55 insertions(+), 51 deletions(-) rename src/main/java/edu/harvard/iq/dataverse/authorization/{OidcUserInfo.java => OIDCUserInfo.java} (92%) rename src/main/java/edu/harvard/iq/dataverse/engine/command/impl/{RegisterOidcUserCommand.java => RegisterOIDCUserCommand.java} (92%) rename src/test/java/edu/harvard/iq/dataverse/engine/command/impl/{RegisterOidcUserCommandTest.java => RegisterOIDCUserCommandTest.java} (89%) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Users.java b/src/main/java/edu/harvard/iq/dataverse/api/Users.java index cc9dee3b678..166465115c8 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Users.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Users.java @@ -272,7 +272,7 @@ public Response getTracesElement(@Context ContainerRequestContext crc, @Context @POST @Path("register") - public Response registerOidcUser(String body) { + public Response registerOIDCUser(String body) { if (!FeatureFlags.API_BEARER_AUTH.enabled()) { return error(Response.Status.INTERNAL_SERVER_ERROR, BundleUtil.getStringFromBundle("users.api.errors.bearerAuthFeatureFlagDisabled")); } @@ -282,7 +282,7 @@ public Response registerOidcUser(String body) { } try { JsonObject userJson = JsonUtil.getJsonObject(body); - execCommand(new RegisterOidcUserCommand(createDataverseRequest(GuestUser.get()), bearerToken.get(), jsonParser().parseUserDTO(userJson))); + execCommand(new RegisterOIDCUserCommand(createDataverseRequest(GuestUser.get()), bearerToken.get(), jsonParser().parseUserDTO(userJson))); } catch (JsonParseException | JsonParsingException e) { return error(Response.Status.BAD_REQUEST, MessageFormat.format(BundleUtil.getStringFromBundle("users.api.errors.jsonParseToUserDTO"), e.getMessage())); } catch (WrappedResponse e) { diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java index 9bfcb03a72b..3ee9bb909f2 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java @@ -39,7 +39,7 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) AuthenticatedUser authUser; try { - authUser = authSvc.lookupUserByOidcBearerToken(bearerToken.get()); + authUser = authSvc.lookupUserByOIDCBearerToken(bearerToken.get()); } catch (AuthorizationException e) { logger.log(Level.WARNING, "Authorization failed: {0}", e.getMessage()); throw new WrappedUnauthorizedAuthErrorResponse(e.getMessage()); diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java index 5124cb0d549..3d46af4f8cf 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -984,10 +984,10 @@ public ApiToken getValidApiTokenForUser(User user) { * @return An instance of {@link AuthenticatedUser} representing the authenticated user. * @throws AuthorizationException If the token is invalid or no OIDC provider is configured. */ - public AuthenticatedUser lookupUserByOidcBearerToken(String bearerToken) throws AuthorizationException { + public AuthenticatedUser lookupUserByOIDCBearerToken(String bearerToken) throws AuthorizationException { // TODO: Get the identifier from an invalidating cache to avoid lookup bursts of the same token. // Tokens in the cache should be removed after some (configurable) time. - OidcUserInfo oidcUserInfo = verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); + OIDCUserInfo oidcUserInfo = verifyOIDCBearerTokenAndGetUserIdentifier(bearerToken); return lookupUser(oidcUserInfo.getUserRecordIdentifier()); } @@ -995,10 +995,10 @@ public AuthenticatedUser lookupUserByOidcBearerToken(String bearerToken) throws * Verifies the given OIDC bearer token and retrieves the corresponding OIDC user info. * * @param bearerToken The OIDC bearer token. - * @return An {@link OidcUserInfo} containing the user's identifier and user info. + * @return An {@link OIDCUserInfo} containing the user's identifier and user info. * @throws AuthorizationException If the token is invalid or if no OIDC providers are available. */ - public OidcUserInfo verifyOidcBearerTokenAndGetUserIdentifier(String bearerToken) throws AuthorizationException { + public OIDCUserInfo verifyOIDCBearerTokenAndGetUserIdentifier(String bearerToken) throws AuthorizationException { try { BearerAccessToken accessToken = BearerAccessToken.parse(bearerToken); List providers = getAvailableOidcProviders(); @@ -1019,7 +1019,7 @@ public OidcUserInfo verifyOidcBearerTokenAndGetUserIdentifier(String bearerToken // If either is present, return the result if (userRecordIdentifier.isPresent() || userInfo.isPresent()) { logger.log(Level.FINE, "Bearer token detected, provider {0} confirmed validity and provided user info", provider.getId()); - return new OidcUserInfo(userRecordIdentifier.get(), userInfo.get()); + return new OIDCUserInfo(userRecordIdentifier.get(), userInfo.get()); } } catch (IOException | OAuth2Exception e) { logger.log(Level.FINE, "Bearer token detected, provider " + provider.getId() + " indicates an invalid Token, skipping", e); diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/OidcUserInfo.java b/src/main/java/edu/harvard/iq/dataverse/authorization/OIDCUserInfo.java similarity index 92% rename from src/main/java/edu/harvard/iq/dataverse/authorization/OidcUserInfo.java rename to src/main/java/edu/harvard/iq/dataverse/authorization/OIDCUserInfo.java index c89ea354172..8c4cf165f18 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/OidcUserInfo.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/OIDCUserInfo.java @@ -14,11 +14,11 @@ * @see UserRecordIdentifier * @see UserInfo */ -public class OidcUserInfo { +public class OIDCUserInfo { private final UserRecordIdentifier userRecordIdentifier; private final UserInfo userClaimsInfo; - public OidcUserInfo(UserRecordIdentifier userRecordIdentifier, UserInfo userClaimsInfo) { + public OIDCUserInfo(UserRecordIdentifier userRecordIdentifier, UserInfo userClaimsInfo) { this.userRecordIdentifier = userRecordIdentifier; this.userClaimsInfo = userClaimsInfo; } diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java similarity index 92% rename from src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java rename to src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java index ff059e71ec6..ed58d548b8b 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java @@ -3,7 +3,7 @@ import edu.harvard.iq.dataverse.DvObject; import edu.harvard.iq.dataverse.api.dto.UserDTO; import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; -import edu.harvard.iq.dataverse.authorization.OidcUserInfo; +import edu.harvard.iq.dataverse.authorization.OIDCUserInfo; import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; import edu.harvard.iq.dataverse.engine.command.*; @@ -17,12 +17,12 @@ import java.util.Map; @RequiredPermissions({}) -public class RegisterOidcUserCommand extends AbstractVoidCommand { +public class RegisterOIDCUserCommand extends AbstractVoidCommand { private final String bearerToken; private final UserDTO userDTO; - public RegisterOidcUserCommand(DataverseRequest aRequest, String bearerToken, UserDTO userDTO) { + public RegisterOIDCUserCommand(DataverseRequest aRequest, String bearerToken, UserDTO userDTO) { super(aRequest, (DvObject) null); this.bearerToken = bearerToken; this.userDTO = userDTO; @@ -71,7 +71,7 @@ private boolean isUsernameInUse(CommandContext ctxt, String username) { private void createUser(CommandContext ctxt) throws CommandException { try { - OidcUserInfo oidcUserInfo = ctxt.authentication().verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); + OIDCUserInfo oidcUserInfo = ctxt.authentication().verifyOIDCBearerTokenAndGetUserIdentifier(bearerToken); UserRecordIdentifier userRecordIdentifier = oidcUserInfo.getUserRecordIdentifier(); if (ctxt.authentication().lookupUser(userRecordIdentifier) != null) { diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java index ecf0e901943..992281f9d70 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java @@ -520,7 +520,7 @@ public void testDeleteAuthenticatedUser() { @Test // This test is disabled because it is only compatible with the containerized development environment and would cause the Jenkins job to fail. @Disabled - public void testRegisterOidcUser() { + public void testRegisterOIDCUser() { // Set Up - Get the admin access token from the OIDC provider Response adminOidcLoginResponse = UtilIT.performKeycloakROPCLogin("admin", "admin"); adminOidcLoginResponse.then().assertThat() diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java index b6f4ec922dd..ab4090eb0a0 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java @@ -44,7 +44,7 @@ void testFindUserFromRequest_no_token() throws WrappedAuthErrorResponse { @Test void testFindUserFromRequest_invalid_token() throws AuthorizationException { String testErrorMessage = "test error"; - Mockito.when(sut.authSvc.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)).thenThrow(new AuthorizationException(testErrorMessage)); + Mockito.when(sut.authSvc.lookupUserByOIDCBearerToken(TEST_BEARER_TOKEN)).thenThrow(new AuthorizationException(testErrorMessage)); // when ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake(TEST_BEARER_TOKEN); @@ -57,7 +57,7 @@ void testFindUserFromRequest_invalid_token() throws AuthorizationException { @Test void testFindUserFromRequest_validToken_accountExists() throws WrappedAuthErrorResponse, AuthorizationException { AuthenticatedUser testAuthenticatedUser = new AuthenticatedUser(); - Mockito.when(sut.authSvc.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)).thenReturn(testAuthenticatedUser); + Mockito.when(sut.authSvc.lookupUserByOIDCBearerToken(TEST_BEARER_TOKEN)).thenReturn(testAuthenticatedUser); Mockito.when(sut.userSvc.updateLastApiUseTime(testAuthenticatedUser)).thenReturn(testAuthenticatedUser); // when @@ -71,7 +71,7 @@ void testFindUserFromRequest_validToken_accountExists() throws WrappedAuthErrorR @Test void testFindUserFromRequest_validToken_noAccount() throws AuthorizationException { - Mockito.when(sut.authSvc.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)).thenReturn(null); + Mockito.when(sut.authSvc.lookupUserByOIDCBearerToken(TEST_BEARER_TOKEN)).thenReturn(null); // when ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake(TEST_BEARER_TOKEN); diff --git a/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java b/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java index b2e4767a27d..a1e51fb3e01 100644 --- a/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java @@ -2,7 +2,9 @@ import com.nimbusds.oauth2.sdk.ParseException; import com.nimbusds.oauth2.sdk.token.BearerAccessToken; +import com.nimbusds.openid.connect.sdk.claims.UserInfo; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; +import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2Exception; import edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthProvider; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.User; @@ -33,81 +35,83 @@ public void setUp() { } @Test - void testLookupUserByOidcBearerToken_no_OidcProvider() { + void testLookupUserByOIDCBearerToken_no_OIDCProvider() { // Given no OIDC providers are configured Mockito.when(sut.authProvidersRegistrationService.getAuthenticationProvidersMap()).thenReturn(Map.of()); - // When invoking lookupUserByOidcBearerToken + // When invoking lookupUserByOIDCBearerToken AuthorizationException exception = assertThrows(AuthorizationException.class, - () -> sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)); + () -> sut.lookupUserByOIDCBearerToken(TEST_BEARER_TOKEN)); // Then the exception message should indicate no OIDC provider is configured assertEquals(BundleUtil.getStringFromBundle("authenticationServiceBean.errors.bearerTokenDetectedNoOIDCProviderConfigured"), exception.getMessage()); } @Test - void testLookupUserByOidcBearerToken_oneProvider_invalidToken_1() throws ParseException, IOException { + void testLookupUserByOIDCBearerToken_oneProvider_invalidToken_1() throws ParseException, IOException { // Given a single OIDC provider that cannot find a user - OIDCAuthProvider oidcAuthProvider = mockOidcAuthProvider("OIEDC"); + OIDCAuthProvider oidcAuthProvider = mockOIDCAuthProvider("OIEDC"); BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.empty()); - // When invoking lookupUserByOidcBearerToken + // When invoking lookupUserByOIDCBearerToken AuthorizationException exception = assertThrows(AuthorizationException.class, - () -> sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)); + () -> sut.lookupUserByOIDCBearerToken(TEST_BEARER_TOKEN)); // Then the exception message should indicate an unauthorized token assertEquals(BundleUtil.getStringFromBundle("authenticationServiceBean.errors.unauthorizedBearerToken"), exception.getMessage()); } @Test - void testLookupUserByOidcBearerToken_oneProvider_invalidToken_2() throws ParseException, IOException { + void testLookupUserByOIDCBearerToken_oneProvider_invalidToken_2() throws ParseException, IOException { // Given a single OIDC provider that throws an IOException - OIDCAuthProvider oidcAuthProvider = mockOidcAuthProvider("OIEDC"); + OIDCAuthProvider oidcAuthProvider = mockOIDCAuthProvider("OIEDC"); BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenThrow(IOException.class); - // When invoking lookupUserByOidcBearerToken + // When invoking lookupUserByOIDCBearerToken AuthorizationException exception = assertThrows(AuthorizationException.class, - () -> sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)); + () -> sut.lookupUserByOIDCBearerToken(TEST_BEARER_TOKEN)); // Then the exception message should indicate an unauthorized token assertEquals(BundleUtil.getStringFromBundle("authenticationServiceBean.errors.unauthorizedBearerToken"), exception.getMessage()); } @Test - void testLookupUserByOidcBearerToken_oneProvider_validToken() throws ParseException, IOException, AuthorizationException { + void testLookupUserByOIDCBearerToken_oneProvider_validToken() throws ParseException, IOException, AuthorizationException, OAuth2Exception { // Given a single OIDC provider that returns a valid user identifier - OIDCAuthProvider oidcAuthProvider = mockOidcAuthProvider("OIEDC"); + OIDCAuthProvider oidcAuthProvider = mockOIDCAuthProvider("OIEDC"); AuthenticatedUser authenticatedUser = setupAuthenticatedUserQueryWithResult(new AuthenticatedUser()); UserRecordIdentifier userInfo = new UserRecordIdentifier("OIEDC", "KEY"); BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.of(userInfo)); + Mockito.when(oidcAuthProvider.getUserInfo(token)).thenReturn(Optional.of(Mockito.mock(UserInfo.class))); - // When invoking lookupUserByOidcBearerToken - User actualUser = sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN); + // When invoking lookupUserByOIDCBearerToken + User actualUser = sut.lookupUserByOIDCBearerToken(TEST_BEARER_TOKEN); // Then the actual user should match the expected authenticated user assertEquals(authenticatedUser, actualUser); } @Test - void testLookupUserByOidcBearerToken_oneProvider_validToken_noAccount() throws ParseException, IOException, AuthorizationException { + void testLookupUserByOIDCBearerToken_oneProvider_validToken_noAccount() throws ParseException, IOException, AuthorizationException, OAuth2Exception { // Given a single OIDC provider with a valid user identifier but no account exists - OIDCAuthProvider oidcAuthProvider = mockOidcAuthProvider("OIEDC"); + OIDCAuthProvider oidcAuthProvider = mockOIDCAuthProvider("OIEDC"); setupAuthenticatedUserQueryWithNoResult(); UserRecordIdentifier userInfo = new UserRecordIdentifier("OIEDC", "KEY"); BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.of(userInfo)); + Mockito.when(oidcAuthProvider.getUserInfo(token)).thenReturn(Optional.of(Mockito.mock(UserInfo.class))); - // When invoking lookupUserByOidcBearerToken - User actualUser = sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN); + // When invoking lookupUserByOIDCBearerToken + User actualUser = sut.lookupUserByOIDCBearerToken(TEST_BEARER_TOKEN); // Then no user should be found, and result should be null assertNull(actualUser); } - private OIDCAuthProvider mockOidcAuthProvider(String providerID) { + private OIDCAuthProvider mockOIDCAuthProvider(String providerID) { OIDCAuthProvider oidcAuthProvider = Mockito.mock(OIDCAuthProvider.class); Mockito.when(oidcAuthProvider.getId()).thenReturn(providerID); Mockito.when(sut.authProvidersRegistrationService.getAuthenticationProvidersMap()).thenReturn(Map.of(providerID, oidcAuthProvider)); diff --git a/src/test/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthenticationProviderFactoryIT.java b/src/test/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthenticationProviderFactoryIT.java index 839781b6b3b..58b792691b9 100644 --- a/src/test/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthenticationProviderFactoryIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthenticationProviderFactoryIT.java @@ -159,7 +159,7 @@ void testApiBearerAuth() throws Exception { AuthenticatedUser user = new MockAuthenticatedUser(); // setup mocks (we don't want or need a database here) - when(authService.lookupUserByOidcBearerToken(token)).thenReturn(user); + when(authService.lookupUserByOIDCBearerToken(token)).thenReturn(user); when(userService.updateLastApiUseTime(user)).thenReturn(user); // when (let's do this again, but now with the actual subject under test!) diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java similarity index 89% rename from src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommandTest.java rename to src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java index 845ad8c3ed9..fb07f24b924 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java @@ -4,7 +4,7 @@ import edu.harvard.iq.dataverse.api.dto.UserDTO; import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; -import edu.harvard.iq.dataverse.authorization.OidcUserInfo; +import edu.harvard.iq.dataverse.authorization.OIDCUserInfo; import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; @@ -25,7 +25,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.*; -class RegisterOidcUserCommandTest { +class RegisterOIDCUserCommandTest { private static final String TEST_BEARER_TOKEN = "Bearer test"; @@ -38,11 +38,11 @@ class RegisterOidcUserCommandTest { private AuthenticationServiceBean authServiceMock; @InjectMocks - private RegisterOidcUserCommand sut; + private RegisterOIDCUserCommand sut; private UserRecordIdentifier userRecordIdentifierMock; private UserInfo userInfoMock; - private OidcUserInfo oidcUserInfoMock; + private OIDCUserInfo OIDCUserInfoMock; private AuthenticatedUser existingTestUser; @BeforeEach @@ -52,11 +52,11 @@ void setUp() { userRecordIdentifierMock = mock(UserRecordIdentifier.class); userInfoMock = mock(UserInfo.class); - oidcUserInfoMock = new OidcUserInfo(userRecordIdentifierMock, userInfoMock); + OIDCUserInfoMock = new OIDCUserInfo(userRecordIdentifierMock, userInfoMock); existingTestUser = new AuthenticatedUser(); when(context.authentication()).thenReturn(authServiceMock); - sut = new RegisterOidcUserCommand(makeRequest(), TEST_BEARER_TOKEN, userDTO); + sut = new RegisterOIDCUserCommand(makeRequest(), TEST_BEARER_TOKEN, userDTO); } private void setUpDefaultUserDTO() { @@ -104,20 +104,20 @@ public void execute_acceptedTerms_availableEmailAndUsername() { @Test void execute_throwsPermissionException_onAuthorizationException() throws AuthorizationException { String testAuthorizationExceptionMessage = "Authorization failed"; - when(context.authentication().verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)) + when(context.authentication().verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)) .thenThrow(new AuthorizationException(testAuthorizationExceptionMessage)); assertThatThrownBy(() -> sut.execute(context)) .isInstanceOf(PermissionException.class) .hasMessageContaining(testAuthorizationExceptionMessage); - verify(context.authentication(), times(1)).verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN); + verify(context.authentication(), times(1)).verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN); } @Test void execute_throwsIllegalCommandException_ifUserAlreadyRegisteredWithToken() throws AuthorizationException { - when(context.authentication().verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)) - .thenReturn(oidcUserInfoMock); + when(context.authentication().verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)) + .thenReturn(OIDCUserInfoMock); when(context.authentication().lookupUser(userRecordIdentifierMock)).thenReturn(new AuthenticatedUser()); assertThatThrownBy(() -> sut.execute(context)) @@ -129,7 +129,7 @@ void execute_throwsIllegalCommandException_ifUserAlreadyRegisteredWithToken() th @Test void execute_happyPath_withoutAffiliationAndPosition() throws AuthorizationException, CommandException { - when(authServiceMock.verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(oidcUserInfoMock); + when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); sut.execute(context); @@ -152,7 +152,7 @@ void execute_happyPath_withAffiliationAndPosition() throws AuthorizationExceptio userDTO.setPosition("test position"); userDTO.setAffiliation("test affiliation"); - when(authServiceMock.verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(oidcUserInfoMock); + when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); sut.execute(context); From dce7edf437b8dc3414e0e10890ccfba835652b55 Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 13 Nov 2024 16:53:40 +0000 Subject: [PATCH 33/62] Changed: using claims as UserDTO fields when available from the IdP --- .../command/impl/RegisterOIDCUserCommand.java | 101 ++++++---- .../iq/dataverse/util/json/JsonParser.java | 150 +++++++-------- src/main/java/propertyFiles/Bundle.properties | 4 + .../edu/harvard/iq/dataverse/api/UsersIT.java | 180 +++++++----------- .../impl/RegisterOIDCUserCommandTest.java | 6 +- 5 files changed, 220 insertions(+), 221 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java index ed58d548b8b..a82e6b57b68 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java @@ -1,5 +1,6 @@ package edu.harvard.iq.dataverse.engine.command.impl; +import com.nimbusds.openid.connect.sdk.claims.UserInfo; import edu.harvard.iq.dataverse.DvObject; import edu.harvard.iq.dataverse.api.dto.UserDTO; import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; @@ -30,35 +31,91 @@ public RegisterOIDCUserCommand(DataverseRequest aRequest, String bearerToken, Us @Override protected void executeImpl(CommandContext ctxt) throws CommandException { - Map fieldErrors = validateUserFields(ctxt); + try { + OIDCUserInfo oidcUserInfo = ctxt.authentication().verifyOIDCBearerTokenAndGetUserIdentifier(bearerToken); + UserRecordIdentifier userRecordIdentifier = oidcUserInfo.getUserRecordIdentifier(); + + if (ctxt.authentication().lookupUser(userRecordIdentifier) != null) { + throw new IllegalCommandException(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userAlreadyRegisteredWithToken"), this); + } - if (!fieldErrors.isEmpty()) { - throw new InvalidFieldsCommandException( - BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.invalidFields"), - this, - fieldErrors + UserInfo userClaimsInfo = oidcUserInfo.getUserClaimsInfo(); + + // Update the UserDTO object with available OIDC user claims; keep existing values if claims are absent + userDTO.setUsername(getValueOrDefault(userClaimsInfo.getPreferredUsername(), userDTO.getUsername())); + userDTO.setFirstName(getValueOrDefault(userClaimsInfo.getGivenName(), userDTO.getFirstName())); + userDTO.setLastName(getValueOrDefault(userClaimsInfo.getFamilyName(), userDTO.getLastName())); + userDTO.setEmailAddress(getValueOrDefault(userClaimsInfo.getEmailAddress(), userDTO.getEmailAddress())); + + AuthenticatedUserDisplayInfo userDisplayInfo = new AuthenticatedUserDisplayInfo( + userDTO.getFirstName(), + userDTO.getLastName(), + userDTO.getEmailAddress(), + userDTO.getAffiliation() != null ? userDTO.getAffiliation() : "", + userDTO.getPosition() != null ? userDTO.getPosition() : "" ); + + Map fieldErrors = validateUserFields(ctxt); + if (!fieldErrors.isEmpty()) { + throw new InvalidFieldsCommandException( + BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.invalidFields"), + this, + fieldErrors + ); + } + + ctxt.authentication().createAuthenticatedUser(userRecordIdentifier, userDTO.getUsername(), userDisplayInfo, true); + + } catch (AuthorizationException ex) { + throw new PermissionException(ex.getMessage(), this, null, null, true); } + } - createUser(ctxt); + private String getValueOrDefault(String oidcValue, String dtoValue) { + return (oidcValue == null || oidcValue.isEmpty()) ? dtoValue : oidcValue; } private Map validateUserFields(CommandContext ctxt) { Map fieldErrors = new HashMap<>(); + validateTermsAccepted(fieldErrors); + validateEmailAddress(ctxt, fieldErrors); + validateUsername(ctxt, fieldErrors); + + validateRequiredField("firstName", userDTO.getFirstName(), "registerOidcUserCommand.errors.firstNameFieldRequired", fieldErrors); + validateRequiredField("lastName", userDTO.getLastName(), "registerOidcUserCommand.errors.lastNameFieldRequired", fieldErrors); + + return fieldErrors; + } + + private void validateTermsAccepted(Map fieldErrors) { if (!userDTO.isTermsAccepted()) { fieldErrors.put("termsAccepted", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms")); } + } - if (isEmailInUse(ctxt, userDTO.getEmailAddress())) { + private void validateEmailAddress(CommandContext ctxt, Map fieldErrors) { + String emailAddress = userDTO.getEmailAddress(); + if (emailAddress == null || emailAddress.isEmpty()) { + fieldErrors.put("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailFieldRequired")); + } else if (isEmailInUse(ctxt, emailAddress)) { fieldErrors.put("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailAddressInUse")); } + } - if (isUsernameInUse(ctxt, userDTO.getUsername())) { + private void validateUsername(CommandContext ctxt, Map fieldErrors) { + String username = userDTO.getUsername(); + if (username == null || username.isEmpty()) { + fieldErrors.put("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.usernameFieldRequired")); + } else if (isUsernameInUse(ctxt, username)) { fieldErrors.put("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.usernameInUse")); } + } - return fieldErrors; + private void validateRequiredField(String fieldName, String fieldValue, String bundleKey, Map fieldErrors) { + if (fieldValue == null || fieldValue.isEmpty()) { + fieldErrors.put(fieldName, BundleUtil.getStringFromBundle(bundleKey)); + } } private boolean isEmailInUse(CommandContext ctxt, String emailAddress) { @@ -68,28 +125,4 @@ private boolean isEmailInUse(CommandContext ctxt, String emailAddress) { private boolean isUsernameInUse(CommandContext ctxt, String username) { return ctxt.authentication().getAuthenticatedUser(username) != null; } - - private void createUser(CommandContext ctxt) throws CommandException { - try { - OIDCUserInfo oidcUserInfo = ctxt.authentication().verifyOIDCBearerTokenAndGetUserIdentifier(bearerToken); - UserRecordIdentifier userRecordIdentifier = oidcUserInfo.getUserRecordIdentifier(); - - if (ctxt.authentication().lookupUser(userRecordIdentifier) != null) { - throw new IllegalCommandException(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userAlreadyRegisteredWithToken"), this); - } - - AuthenticatedUserDisplayInfo userInfo = new AuthenticatedUserDisplayInfo( - userDTO.getFirstName(), - userDTO.getLastName(), - userDTO.getEmailAddress(), - userDTO.getAffiliation() != null ? userDTO.getAffiliation() : "", - userDTO.getPosition() != null ? userDTO.getPosition() : "" - ); - - ctxt.authentication().createAuthenticatedUser(userRecordIdentifier, userDTO.getUsername(), userInfo, true); - - } catch (AuthorizationException ex) { - throw new PermissionException(ex.getMessage(), this, null, null, true); - } - } } diff --git a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java index f23ea7dda4f..1656c897ea1 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java @@ -78,11 +78,11 @@ public class JsonParser { DatasetTypeServiceBean datasetTypeService; HarvestingClient harvestingClient = null; boolean allowHarvestingMissingCVV = false; - + /** * if lenient, we will accept alternate spellings for controlled vocabulary values */ - boolean lenient = false; + boolean lenient = false; @Deprecated public JsonParser(DatasetFieldServiceBean datasetFieldSvc, MetadataBlockServiceBean blockService, SettingsServiceBean settingsService) { @@ -94,7 +94,7 @@ public JsonParser(DatasetFieldServiceBean datasetFieldSvc, MetadataBlockServiceB public JsonParser(DatasetFieldServiceBean datasetFieldSvc, MetadataBlockServiceBean blockService, SettingsServiceBean settingsService, LicenseServiceBean licenseService, DatasetTypeServiceBean datasetTypeService) { this(datasetFieldSvc, blockService, settingsService, licenseService, datasetTypeService, null); } - + public JsonParser(DatasetFieldServiceBean datasetFieldSvc, MetadataBlockServiceBean blockService, SettingsServiceBean settingsService, LicenseServiceBean licenseService, DatasetTypeServiceBean datasetTypeService, HarvestingClient harvestingClient) { this.datasetFieldSvc = datasetFieldSvc; this.blockService = blockService; @@ -108,7 +108,7 @@ public JsonParser(DatasetFieldServiceBean datasetFieldSvc, MetadataBlockServiceB public JsonParser() { this( null,null,null ); } - + public boolean isLenient() { return lenient; } @@ -328,10 +328,10 @@ public IpGroup parseIpGroup(JsonObject obj) { return retVal; } - + public MailDomainGroup parseMailDomainGroup(JsonObject obj) throws JsonParseException { MailDomainGroup grp = new MailDomainGroup(); - + if (obj.containsKey("id")) { grp.setId(obj.getJsonNumber("id").longValue()); } @@ -355,7 +355,7 @@ public MailDomainGroup parseMailDomainGroup(JsonObject obj) throws JsonParseExce } else { throw new JsonParseException("Field domains is mandatory."); } - + return grp; } @@ -393,7 +393,7 @@ public Dataset parseDataset(JsonObject obj) throws JsonParseException { throw new JsonParseException("Invalid dataset type: " + datasetTypeIn); } - DatasetVersion dsv = new DatasetVersion(); + DatasetVersion dsv = new DatasetVersion(); dsv.setDataset(dataset); dsv = parseDatasetVersion(obj.getJsonObject("datasetVersion"), dsv); List versions = new ArrayList<>(1); @@ -424,7 +424,7 @@ public DatasetVersion parseDatasetVersion(JsonObject obj, DatasetVersion dsv) th if (dsv.getId()==null) { dsv.setId(parseLong(obj.getString("id", null))); } - + String versionStateStr = obj.getString("versionState", null); if (versionStateStr != null) { dsv.setVersionState(DatasetVersion.VersionState.valueOf(versionStateStr)); @@ -437,8 +437,8 @@ public DatasetVersion parseDatasetVersion(JsonObject obj, DatasetVersion dsv) th // Terms of Use related fields TermsOfUseAndAccess terms = new TermsOfUseAndAccess(); - License license = null; - + License license = null; + try { // This method will attempt to parse the license in the format // in which it appears in our json exports, as a compound @@ -457,7 +457,7 @@ public DatasetVersion parseDatasetVersion(JsonObject obj, DatasetVersion dsv) th // "license" : "CC0 1.0" license = parseLicense(obj.getString("license", null)); } - + if (license == null) { terms.setLicense(license); terms.setTermsOfUse(obj.getString("termsOfUse", null)); @@ -495,13 +495,13 @@ public DatasetVersion parseDatasetVersion(JsonObject obj, DatasetVersion dsv) th dsv.setFileMetadatas(parseFiles(filesJson, dsv)); } return dsv; - } catch (ParseException ex) { + } catch (ParseException ex) { throw new JsonParseException(BundleUtil.getStringFromBundle("jsonparser.error.parsing.date", Arrays.asList(ex.getMessage())) , ex); } catch (NumberFormatException ex) { throw new JsonParseException(BundleUtil.getStringFromBundle("jsonparser.error.parsing.number", Arrays.asList(ex.getMessage())), ex); } } - + private edu.harvard.iq.dataverse.license.License parseLicense(String licenseNameOrUri) throws JsonParseException { if (licenseNameOrUri == null){ boolean safeDefaultIfKeyNotFound = true; @@ -515,7 +515,7 @@ private edu.harvard.iq.dataverse.license.License parseLicense(String licenseName if (license == null) throw new JsonParseException("Invalid license: " + licenseNameOrUri); return license; } - + private edu.harvard.iq.dataverse.license.License parseLicense(JsonObject licenseObj) throws JsonParseException { if (licenseObj == null){ boolean safeDefaultIfKeyNotFound = true; @@ -525,12 +525,12 @@ private edu.harvard.iq.dataverse.license.License parseLicense(JsonObject license return licenseService.getDefault(); } } - + String licenseName = licenseObj.getString("name", null); String licenseUri = licenseObj.getString("uri", null); - - License license = null; - + + License license = null; + // If uri is provided, we'll try that first. This is an easier lookup // method; the uri is always the same. The name may have been customized // (translated) on this instance, so we may be dealing with such translated @@ -540,17 +540,17 @@ private edu.harvard.iq.dataverse.license.License parseLicense(JsonObject license if (licenseUri != null) { license = licenseService.getByNameOrUri(licenseUri); } - + if (license != null) { return license; } - + if (licenseName == null) { - String exMsg = "Invalid or unsupported license section submitted" + String exMsg = "Invalid or unsupported license section submitted" + (licenseUri != null ? ": " + licenseUri : "."); - throw new JsonParseException("Invalid or unsupported license section submitted."); + throw new JsonParseException("Invalid or unsupported license section submitted."); } - + license = licenseService.getByPotentiallyLocalizedName(licenseName); if (license == null) { throw new JsonParseException("Invalid or unsupported license: " + licenseName); @@ -569,13 +569,13 @@ public List parseMetadataBlocks(JsonObject json) throws JsonParseE } return fields; } - + public List parseMultipleFields(JsonObject json) throws JsonParseException { JsonArray fieldsJson = json.getJsonArray("fields"); List fields = parseFieldsFromArray(fieldsJson, false); return fields; } - + public List parseMultipleFieldsForDelete(JsonObject json) throws JsonParseException { List fields = new LinkedList<>(); for (JsonObject fieldJson : json.getJsonArray("fields").getValuesAs(JsonObject.class)) { @@ -583,7 +583,7 @@ public List parseMultipleFieldsForDelete(JsonObject json) throws J } return fields; } - + private List parseFieldsFromArray(JsonArray fieldsArray, Boolean testType) throws JsonParseException { List fields = new LinkedList<>(); for (JsonObject fieldJson : fieldsArray.getValuesAs(JsonObject.class)) { @@ -595,18 +595,18 @@ private List parseFieldsFromArray(JsonArray fieldsArray, Boolean t } catch (CompoundVocabularyException ex) { DatasetFieldType fieldType = datasetFieldSvc.findByNameOpt(fieldJson.getString("typeName", "")); if (lenient && (DatasetFieldConstant.geographicCoverage).equals(fieldType.getName())) { - fields.add(remapGeographicCoverage( ex)); + fields.add(remapGeographicCoverage( ex)); } else { // if not lenient mode, re-throw exception throw ex; } - } + } } return fields; - + } - + public List parseFiles(JsonArray metadatasJson, DatasetVersion dsv) throws JsonParseException { List fileMetadatas = new LinkedList<>(); if (metadatasJson != null) { @@ -620,7 +620,7 @@ public List parseFiles(JsonArray metadatasJson, DatasetVersion dsv fileMetadata.setDirectoryLabel(directoryLabel); fileMetadata.setDescription(description); fileMetadata.setDatasetVersion(dsv); - + if ( filemetadataJson.containsKey("dataFile") ) { DataFile dataFile = parseDataFile(filemetadataJson.getJsonObject("dataFile")); dataFile.getFileMetadatas().add(fileMetadata); @@ -633,7 +633,7 @@ public List parseFiles(JsonArray metadatasJson, DatasetVersion dsv dsv.getDataset().getFiles().add(dataFile); } } - + fileMetadatas.add(fileMetadata); fileMetadata.setCategories(getCategories(filemetadataJson, dsv.getDataset())); } @@ -641,19 +641,19 @@ public List parseFiles(JsonArray metadatasJson, DatasetVersion dsv return fileMetadatas; } - + public DataFile parseDataFile(JsonObject datafileJson) { DataFile dataFile = new DataFile(); - + Timestamp timestamp = new Timestamp(new Date().getTime()); dataFile.setCreateDate(timestamp); dataFile.setModificationTime(timestamp); dataFile.setPermissionModificationTime(timestamp); - + if ( datafileJson.containsKey("filesize") ) { dataFile.setFilesize(datafileJson.getJsonNumber("filesize").longValueExact()); } - + String contentType = datafileJson.getString("contentType", null); if (contentType == null) { contentType = "application/octet-stream"; @@ -716,21 +716,21 @@ public DataFile parseDataFile(JsonObject datafileJson) { // TODO: // unf (if available)... etc.? - + dataFile.setContentType(contentType); dataFile.setStorageIdentifier(storageIdentifier); - + return dataFile; } /** * Special processing for GeographicCoverage compound field: * Handle parsing exceptions caused by invalid controlled vocabulary in the "country" field by * putting the invalid data in "otherGeographicCoverage" in a new compound value. - * + * * @param ex - contains the invalid values to be processed - * @return a compound DatasetField that contains the newly created values, in addition to + * @return a compound DatasetField that contains the newly created values, in addition to * the original valid values. - * @throws JsonParseException + * @throws JsonParseException */ private DatasetField remapGeographicCoverage(CompoundVocabularyException ex) throws JsonParseException{ List> geoCoverageList = new ArrayList<>(); @@ -757,23 +757,23 @@ private DatasetField remapGeographicCoverage(CompoundVocabularyException ex) thr } return geoCoverageField; } - - + + public DatasetField parseFieldForDelete(JsonObject json) throws JsonParseException{ DatasetField ret = new DatasetField(); - DatasetFieldType type = datasetFieldSvc.findByNameOpt(json.getString("typeName", "")); + DatasetFieldType type = datasetFieldSvc.findByNameOpt(json.getString("typeName", "")); if (type == null) { throw new JsonParseException("Can't find type '" + json.getString("typeName", "") + "'"); } return ret; } - - + + public DatasetField parseField(JsonObject json) throws JsonParseException{ return parseField(json, true); } - - + + public DatasetField parseField(JsonObject json, Boolean testType) throws JsonParseException { if (json == null) { return null; @@ -781,7 +781,7 @@ public DatasetField parseField(JsonObject json, Boolean testType) throws JsonPar DatasetField ret = new DatasetField(); DatasetFieldType type = datasetFieldSvc.findByNameOpt(json.getString("typeName", "")); - + if (type == null) { logger.fine("Can't find type '" + json.getString("typeName", "") + "'"); @@ -799,8 +799,8 @@ public DatasetField parseField(JsonObject json, Boolean testType) throws JsonPar if (testType && type.isControlledVocabulary() && !json.getString("typeClass").equals("controlledVocabulary")) { throw new JsonParseException("incorrect typeClass for field " + json.getString("typeName", "") + ", should be controlledVocabulary"); } - - + + ret.setDatasetFieldType(type); if (type.isCompound()) { @@ -813,11 +813,11 @@ public DatasetField parseField(JsonObject json, Boolean testType) throws JsonPar return ret; } - + public void parseCompoundValue(DatasetField dsf, DatasetFieldType compoundType, JsonObject json) throws JsonParseException { parseCompoundValue(dsf, compoundType, json, true); } - + public void parseCompoundValue(DatasetField dsf, DatasetFieldType compoundType, JsonObject json, Boolean testType) throws JsonParseException { List vocabExceptions = new ArrayList<>(); List vals = new LinkedList<>(); @@ -839,7 +839,7 @@ public void parseCompoundValue(DatasetField dsf, DatasetFieldType compoundType, } catch(ControlledVocabularyException ex) { vocabExceptions.add(ex); } - + if (f!=null) { if (!compoundType.getChildDatasetFieldTypes().contains(f.getDatasetFieldType())) { throw new JsonParseException("field " + f.getDatasetFieldType().getName() + " is not a child of " + compoundType.getName()); @@ -856,10 +856,10 @@ public void parseCompoundValue(DatasetField dsf, DatasetFieldType compoundType, order++; } - + } else { - + DatasetFieldCompoundValue cv = new DatasetFieldCompoundValue(); List fields = new LinkedList<>(); JsonObject value = json.getJsonObject("value"); @@ -880,7 +880,7 @@ public void parseCompoundValue(DatasetField dsf, DatasetFieldType compoundType, cv.setChildDatasetFields(fields); vals.add(cv); } - + } if (!vocabExceptions.isEmpty()) { throw new CompoundVocabularyException( "Invalid controlled vocabulary in compound field ", vocabExceptions, vals); @@ -919,7 +919,7 @@ public void parsePrimitiveValue(DatasetField dsf, DatasetFieldType dft , JsonObj try {json.getString("value");} catch (ClassCastException cce) { throw new JsonParseException("Invalid value submitted for " + dft.getName() + ". It should be a single value."); - } + } DatasetFieldValue datasetFieldValue = new DatasetFieldValue(); datasetFieldValue.setValue(json.getString("value", "").trim()); datasetFieldValue.setDatasetField(dsf); @@ -933,7 +933,7 @@ public void parsePrimitiveValue(DatasetField dsf, DatasetFieldType dft , JsonObj dsf.setDatasetFieldValues(vals); } - + public Workflow parseWorkflow(JsonObject json) throws JsonParseException { Workflow retVal = new Workflow(); validate("", json, "name", ValueType.STRING); @@ -947,12 +947,12 @@ public Workflow parseWorkflow(JsonObject json) throws JsonParseException { retVal.setSteps(steps); return retVal; } - + public WorkflowStepData parseStepData( JsonObject json ) throws JsonParseException { WorkflowStepData wsd = new WorkflowStepData(); validate("step", json, "provider", ValueType.STRING); validate("step", json, "stepType", ValueType.STRING); - + wsd.setProviderId(json.getString("provider")); wsd.setStepType(json.getString("stepType")); if ( json.containsKey("parameters") ) { @@ -969,7 +969,7 @@ public WorkflowStepData parseStepData( JsonObject json ) throws JsonParseExcepti } return wsd; } - + private String jsonValueToString(JsonValue jv) { switch ( jv.getValueType() ) { case STRING: return ((JsonString)jv).getString(); @@ -1049,11 +1049,11 @@ Long parseLong(String str) throws NumberFormatException { int parsePrimitiveInt(String str, int defaultValue) { return str == null ? defaultValue : Integer.parseInt(str); } - + public String parseHarvestingClient(JsonObject obj, HarvestingClient harvestingClient) throws JsonParseException { - + String dataverseAlias = obj.getString("dataverseAlias",null); - + harvestingClient.setName(obj.getString("nickName",null)); harvestingClient.setHarvestStyle(obj.getString("style", "default")); harvestingClient.setHarvestingUrl(obj.getString("harvestUrl",null)); @@ -1088,7 +1088,7 @@ private List getCategories(JsonObject filemetadataJson, Datase } return dataFileCategories; } - + /** * Validate than a JSON object has a field of an expected type, or throw an * inforamtive exception. @@ -1096,10 +1096,10 @@ private List getCategories(JsonObject filemetadataJson, Datase * @param jobject * @param fieldName * @param expectedValueType - * @throws JsonParseException + * @throws JsonParseException */ private void validate(String objectName, JsonObject jobject, String fieldName, ValueType expectedValueType) throws JsonParseException { - if ( (!jobject.containsKey(fieldName)) + if ( (!jobject.containsKey(fieldName)) || (jobject.get(fieldName).getValueType()!=expectedValueType) ) { throw new JsonParseException( objectName + " missing a field named '"+fieldName+"' of type " + expectedValueType ); } @@ -1107,13 +1107,13 @@ private void validate(String objectName, JsonObject jobject, String fieldName, V public UserDTO parseUserDTO(JsonObject jobj) throws JsonParseException { UserDTO userDTO = new UserDTO(); - userDTO.setUsername(getMandatoryString(jobj, "username")); - userDTO.setEmailAddress(getMandatoryString(jobj, "emailAddress")); - userDTO.setFirstName(getMandatoryString(jobj, "firstName")); - userDTO.setLastName(getMandatoryString(jobj, "lastName")); + userDTO.setUsername(jobj.getString("username", null)); + userDTO.setEmailAddress(jobj.getString("emailAddress", null)); + userDTO.setFirstName(jobj.getString("firstName", null)); + userDTO.setLastName(jobj.getString("lastName", null)); userDTO.setTermsAccepted(getMandatoryBoolean(jobj, "termsAccepted")); - userDTO.setAffiliation(jobj.getString("affiliation")); - userDTO.setPosition(jobj.getString("position")); + userDTO.setAffiliation(jobj.getString("affiliation", null)); + userDTO.setPosition(jobj.getString("position", null)); return userDTO; } } diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index 1ae846c338e..34c4334dbdb 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3072,6 +3072,10 @@ users.api.userRegistered=User registered. registerOidcUserCommand.errors.userAlreadyRegisteredWithToken=User is already registered with this token. registerOidcUserCommand.errors.invalidFields=The provided fields are invalid for registering a new user. registerOidcUserCommand.errors.userShouldAcceptTerms=Terms should be accepted. +registerOidcUserCommand.errors.emailFieldRequired=It is required to include an emailAddress field in the request JSON for registering the user. +registerOidcUserCommand.errors.usernameFieldRequired=It is required to include a username field in the request JSON for registering the user. +registerOidcUserCommand.errors.firstNameFieldRequired=It is required to include a firstName field in the request JSON for registering the user. +registerOidcUserCommand.errors.lastNameFieldRequired=It is required to include a lastName field in the request JSON for registering the user. registerOidcUserCommand.errors.emailAddressInUse=Email already in use. registerOidcUserCommand.errors.usernameInUse=Username already in use. diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java index 992281f9d70..acd5bd658e0 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java @@ -528,9 +528,10 @@ public void testRegisterOIDCUser() { .body("access_token", notNullValue()); String adminOidcAccessToken = adminOidcLoginResponse.jsonPath().getString("access_token"); - // Set Up - Create random user in the OIDC provider + // Set Up - Create random user in the OIDC provider without some necessary claims (email, firstName and lastName) String randomUsername = UUID.randomUUID().toString().substring(0, 8); - String newKeycloakUserJson = "{" + + String newKeycloakUserWithoutClaimsJson = "{" + "\"username\":\"" + randomUsername + "\"," + "\"enabled\":true," + "\"credentials\":[" @@ -541,10 +542,39 @@ public void testRegisterOIDCUser() { + " }" + "]" + "}"; - Response createKeycloakOidcUserResponse = UtilIT.createKeycloakUser(adminOidcAccessToken, newKeycloakUserJson); + + Response createKeycloakOidcUserResponse = UtilIT.createKeycloakUser(adminOidcAccessToken, newKeycloakUserWithoutClaimsJson); createKeycloakOidcUserResponse.then().assertThat().statusCode(CREATED.getStatusCode()); + Response newUserOidcLoginResponse = UtilIT.performKeycloakROPCLogin(randomUsername, "password"); - String newUserOidcAccessToken = newUserOidcLoginResponse.jsonPath().getString("access_token"); + String userWithoutClaimsAccessToken = newUserOidcLoginResponse.jsonPath().getString("access_token"); + + // Set Up - Create a second random user in the OIDC provider with all necessary claims (including email, firstName and lastName) + randomUsername = UUID.randomUUID().toString().substring(0, 8); + String email = randomUsername + "@dataverse.org"; + String firstName = "John"; + String lastName = "Doe"; + + String newKeycloakUserWithClaimsJson = "{" + + "\"username\":\"" + randomUsername + "\"," + + "\"enabled\":true," + + "\"email\":\"" + email + "\"," + + "\"firstName\":\"" + firstName + "\"," + + "\"lastName\":\"" + lastName + "\"," + + "\"credentials\":[" + + " {" + + " \"type\":\"password\"," + + " \"value\":\"password\"," + + " \"temporary\":false" + + " }" + + "]" + + "}"; + + Response createKeycloakOidcUserWithClaimsResponse = UtilIT.createKeycloakUser(adminOidcAccessToken, newKeycloakUserWithClaimsJson); + createKeycloakOidcUserWithClaimsResponse.then().assertThat().statusCode(CREATED.getStatusCode()); + + Response newUserWithClaimsOidcLoginResponse = UtilIT.performKeycloakROPCLogin(randomUsername, "password"); + String userWithClaimsAccessToken = newUserWithClaimsOidcLoginResponse.jsonPath().getString("access_token"); // Should return error when empty token is passed Response registerOidcUserResponse = UtilIT.registerOidcUser( @@ -555,77 +585,29 @@ public void testRegisterOIDCUser() { .statusCode(BAD_REQUEST.getStatusCode()) .body("message", equalTo(BundleUtil.getStringFromBundle("users.api.errors.bearerTokenRequired"))); - // Should return error when a required field in the User JSON is missing (username) - registerOidcUserResponse = UtilIT.registerOidcUser( - "{" - + "\"firstName\":\"YourFirstName\"," - + "\"lastName\":\"YourLastName\"," - + "\"emailAddress\":\"yourEmail@example.com\"," - + "\"affiliation\":\"YourAffiliation\"," - + "\"position\":\"YourPosition\"," - + "\"termsAccepted\":true" - + "}", - "Bearer testBearerToken" - ); - registerOidcUserResponse.then().assertThat() - .statusCode(BAD_REQUEST.getStatusCode()) - .body("message", equalTo("Error parsing the POSTed User json: Field 'username' is mandatory")); - - // Should return error when a required field in the User JSON is missing (firstName) - registerOidcUserResponse = UtilIT.registerOidcUser( - "{" - + "\"username\":\"yourUsername\"," - + "\"lastName\":\"YourLastName\"," - + "\"emailAddress\":\"yourEmail@example.com\"," - + "\"affiliation\":\"YourAffiliation\"," - + "\"position\":\"YourPosition\"," - + "\"termsAccepted\":true" - + "}", - "Bearer testBearerToken" - ); - registerOidcUserResponse.then().assertThat() - .statusCode(BAD_REQUEST.getStatusCode()) - .body("message", equalTo("Error parsing the POSTed User json: Field 'firstName' is mandatory")); - - // Should return error when a required field in the User JSON is missing (lastName) + // Should return error when a malformed User JSON is sent registerOidcUserResponse = UtilIT.registerOidcUser( - "{" - + "\"username\":\"yourUsername\"," - + "\"firstName\":\"YourFirstName\"," - + "\"emailAddress\":\"yourEmail@example.com\"," - + "\"affiliation\":\"YourAffiliation\"," - + "\"position\":\"YourPosition\"," - + "\"termsAccepted\":true" - + "}", + "{{{user:abcde}", "Bearer testBearerToken" ); registerOidcUserResponse.then().assertThat() .statusCode(BAD_REQUEST.getStatusCode()) - .body("message", equalTo("Error parsing the POSTed User json: Field 'lastName' is mandatory")); + .body("message", equalTo("Error parsing the POSTed User json: Invalid token=CURLYOPEN at (line no=1, column no=2, offset=1). Expected tokens are: [STRING]")); - // Should return error when a required field in the User JSON is missing (emailAddress) + // Should return error when the provided User JSON is valid but the provided Bearer token is invalid registerOidcUserResponse = UtilIT.registerOidcUser( "{" - + "\"username\":\"yourUsername\"," - + "\"firstName\":\"YourFirstName\"," - + "\"lastName\":\"YourLastName\"," - + "\"affiliation\":\"YourAffiliation\"," - + "\"position\":\"YourPosition\"," + "\"termsAccepted\":true" + "}", "Bearer testBearerToken" ); registerOidcUserResponse.then().assertThat() - .statusCode(BAD_REQUEST.getStatusCode()) - .body("message", equalTo("Error parsing the POSTed User json: Field 'emailAddress' is mandatory")); + .statusCode(UNAUTHORIZED.getStatusCode()) + .body("message", equalTo("Unauthorized bearer token.")); - // Should return error when a required field in the User JSON is missing (termsAccepted) + // Should return an error when the termsAccepted field is missing in the User JSON registerOidcUserResponse = UtilIT.registerOidcUser( "{" - + "\"username\":\"yourUsername\"," - + "\"firstName\":\"YourFirstName\"," - + "\"lastName\":\"YourLastName\"," - + "\"emailAddress\":\"yourEmail@example.com\"," + "\"affiliation\":\"YourAffiliation\"," + "\"position\":\"YourPosition\"" + "}", @@ -635,59 +617,29 @@ public void testRegisterOIDCUser() { .statusCode(BAD_REQUEST.getStatusCode()) .body("message", equalTo("Error parsing the POSTed User json: Field 'termsAccepted' is mandatory")); - // Should return error when a malformed User JSON is sent - registerOidcUserResponse = UtilIT.registerOidcUser( - "{{{user:abcde}", - "Bearer testBearerToken" - ); - registerOidcUserResponse.then().assertThat() - .statusCode(BAD_REQUEST.getStatusCode()) - .body("message", equalTo("Error parsing the POSTed User json: Invalid token=CURLYOPEN at (line no=1, column no=2, offset=1). Expected tokens are: [STRING]")); - - // Should return error when the provided User JSON have invalid fields + // Should return an error when the Bearer token is valid but required claims are missing in the IdP, needing completion from the request JSON registerOidcUserResponse = UtilIT.registerOidcUser( "{" - + "\"username\":\"dataverseAdmin\"," - + "\"firstName\":\"YourFirstName\"," - + "\"lastName\":\"YourLastName\"," - + "\"emailAddress\":\"dataverse@mailinator.com\"," - + "\"affiliation\":\"YourAffiliation\"," - + "\"position\":\"YourPosition\"," - + "\"termsAccepted\":false" + + "\"termsAccepted\":true" + "}", - "Bearer testBearerToken" + "Bearer " + userWithoutClaimsAccessToken ); registerOidcUserResponse.then().assertThat() .statusCode(BAD_REQUEST.getStatusCode()) .body("message", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.invalidFields"))) - .body("fieldErrors.emailAddress", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailAddressInUse"))) - .body("fieldErrors.termsAccepted", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms"))) - .body("fieldErrors.username", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.usernameInUse"))); + .body("fieldErrors.firstName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.firstNameFieldRequired"))) + .body("fieldErrors.lastName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.lastNameFieldRequired"))) + .body("fieldErrors.emailAddress", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailFieldRequired"))); - // Should return error when the provided User JSON is valid but the provided Bearer token is invalid - randomUsername = UUID.randomUUID().toString().substring(0, 8); - String randomEmail = randomUsername + "@dataverse.com"; - String validUserJson = "{" - + "\"username\":\"" + randomUsername + "\"," - + "\"firstName\":\"YourFirstName\"," - + "\"lastName\":\"YourLastName\"," - + "\"emailAddress\":\"" + randomEmail + "\"," - + "\"affiliation\":\"YourAffiliation\"," - + "\"position\":\"YourPosition\"," - + "\"termsAccepted\":true" - + "}"; + // Should register user when the Bearer token is valid and the provided User JSON contains the missing claims in the IdP registerOidcUserResponse = UtilIT.registerOidcUser( - validUserJson, - "Bearer testBearerToken" - ); - registerOidcUserResponse.then().assertThat() - .statusCode(UNAUTHORIZED.getStatusCode()) - .body("message", equalTo("Unauthorized bearer token.")); - - // Should register user when the provided User JSON is valid and the provided Bearer token is valid - registerOidcUserResponse = UtilIT.registerOidcUser( - validUserJson, - "Bearer " + newUserOidcAccessToken + "{" + + "\"firstName\":\"testFirstName\"," + + "\"lastName\":\"testLastName\"," + + "\"emailAddress\":\"" + UUID.randomUUID().toString().substring(0, 8) + "@dataverse.org\"," + + "\"termsAccepted\":true" + + "}", + "Bearer " + userWithoutClaimsAccessToken ); registerOidcUserResponse.then().assertThat() .statusCode(OK.getStatusCode()) @@ -695,21 +647,29 @@ public void testRegisterOIDCUser() { // Should return error when attempting to re-register with the same Bearer token but different User data String newUserJson = "{" - + "\"username\":\"newUsername\"," - + "\"firstName\":\"NewFirstName\"," - + "\"lastName\":\"NewLastName\"," - + "\"emailAddress\":\"newEmail@example.com\"," - + "\"affiliation\":\"YourAffiliation\"," - + "\"position\":\"YourPosition\"," + + "\"firstName\":\"newFirstName\"," + + "\"lastName\":\"newLastName\"," + + "\"emailAddress\":\"newEmail@dataverse.com\"," + "\"termsAccepted\":true" + "}"; registerOidcUserResponse = UtilIT.registerOidcUser( newUserJson, - "Bearer " + newUserOidcAccessToken + "Bearer " + userWithoutClaimsAccessToken ); registerOidcUserResponse.then().assertThat() .statusCode(FORBIDDEN.getStatusCode()) .body("message", equalTo("User is already registered with this token.")); + + // Should register user when the Bearer token is valid and all required claims are present in the IdP, requiring only minimal data in the User JSON + registerOidcUserResponse = UtilIT.registerOidcUser( + "{" + + "\"termsAccepted\":true" + + "}", + "Bearer " + userWithClaimsAccessToken + ); + registerOidcUserResponse.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.message", equalTo("User registered.")); } private Response convertUserFromBcryptToSha1(long idOfBcryptUserToConvert, String password) { diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java index fb07f24b924..bd9edf150f6 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java @@ -69,10 +69,11 @@ private void setUpDefaultUserDTO() { } @Test - public void execute_unacceptedTerms_availableEmailAndUsername() { + public void execute_unacceptedTerms_availableEmailAndUsername() throws AuthorizationException { userDTO.setTermsAccepted(false); when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(null); when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(null); + when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); assertThatThrownBy(() -> sut.execute(context)) .isInstanceOf(InvalidFieldsCommandException.class) @@ -86,9 +87,10 @@ public void execute_unacceptedTerms_availableEmailAndUsername() { } @Test - public void execute_acceptedTerms_availableEmailAndUsername() { + public void execute_acceptedTerms_availableEmailAndUsername() throws AuthorizationException { when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(existingTestUser); when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(existingTestUser); + when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); assertThatThrownBy(() -> sut.execute(context)) .isInstanceOf(InvalidFieldsCommandException.class) From 9a62528e704b65878b6b309e5400f6d0c1a93848 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 15 Nov 2024 12:06:20 +0000 Subject: [PATCH 34/62] Added: API_BEARER_AUTH_JSON_CLAIMS feature flag --- .../harvard/iq/dataverse/settings/FeatureFlags.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java index 20632c170e4..5c9e1d6279c 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java @@ -36,6 +36,18 @@ public enum FeatureFlags { * @since Dataverse @TODO: */ API_BEARER_AUTH("api-bearer-auth"), + /** + * Enables sending the missing user claims from the JSON provided during OIDC user registration + * (see API endpoint /users/register) when these claims are not returned by the identity provider + * but are necessary for registering the IdP user in Dataverse. + * + *

The value of this feature flag is only considered when the feature flag + * {@link #API_BEARER_AUTH} is enabled.

+ * + * @apiNote Raise flag by setting "dataverse.feature.api-bearer-auth-json-claims" + * @since Dataverse @TODO: + */ + API_BEARER_AUTH_JSON_CLAIMS("api-bearer-auth-json-claims"), /** * For published (public) objects, don't use a join when searching Solr. * Experimental! Requires a reindex with the following feature flag enabled, From 0f2cfdcfe50ea9caca7b47023b70b7aff2a562ca Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 15 Nov 2024 12:38:52 +0000 Subject: [PATCH 35/62] Changed: renamed flag API_BEARER_AUTH_PROVIDE_MISSING_CLAIMS --- .../edu/harvard/iq/dataverse/settings/FeatureFlags.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java index 5c9e1d6279c..42f37034d90 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java @@ -37,17 +37,17 @@ public enum FeatureFlags { */ API_BEARER_AUTH("api-bearer-auth"), /** - * Enables sending the missing user claims from the JSON provided during OIDC user registration + * Enables sending the missing user claims in the request JSON provided during OIDC user registration * (see API endpoint /users/register) when these claims are not returned by the identity provider - * but are necessary for registering the IdP user in Dataverse. + * but are necessary for registering the user in Dataverse. * *

The value of this feature flag is only considered when the feature flag * {@link #API_BEARER_AUTH} is enabled.

* - * @apiNote Raise flag by setting "dataverse.feature.api-bearer-auth-json-claims" + * @apiNote Raise flag by setting "dataverse.feature.api-bearer-auth-provide-missing-claims" * @since Dataverse @TODO: */ - API_BEARER_AUTH_JSON_CLAIMS("api-bearer-auth-json-claims"), + API_BEARER_AUTH_PROVIDE_MISSING_CLAIMS("api-bearer-auth-provide-missing-claims"), /** * For published (public) objects, don't use a join when searching Solr. * Experimental! Requires a reindex with the following feature flag enabled, From 52a5a9e8d59ed041814269bb16a7f3fad990472c Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 15 Nov 2024 12:39:51 +0000 Subject: [PATCH 36/62] Added: API_BEARER_AUTH_PROVIDE_MISSING_CLAIMS management an different logic paths depending on the value to RegisterOIDCUserCommand --- .../command/impl/RegisterOIDCUserCommand.java | 77 ++++++++++--------- src/main/java/propertyFiles/Bundle.properties | 12 ++- 2 files changed, 47 insertions(+), 42 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java index a82e6b57b68..57bf7832f62 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java @@ -12,6 +12,7 @@ import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; import edu.harvard.iq.dataverse.engine.command.exception.PermissionException; import edu.harvard.iq.dataverse.engine.command.exception.InvalidFieldsCommandException; +import edu.harvard.iq.dataverse.settings.FeatureFlags; import edu.harvard.iq.dataverse.util.BundleUtil; import java.util.HashMap; @@ -40,12 +41,9 @@ protected void executeImpl(CommandContext ctxt) throws CommandException { } UserInfo userClaimsInfo = oidcUserInfo.getUserClaimsInfo(); + boolean provideMissingClaimsEnabled = FeatureFlags.API_BEARER_AUTH_PROVIDE_MISSING_CLAIMS.enabled(); - // Update the UserDTO object with available OIDC user claims; keep existing values if claims are absent - userDTO.setUsername(getValueOrDefault(userClaimsInfo.getPreferredUsername(), userDTO.getUsername())); - userDTO.setFirstName(getValueOrDefault(userClaimsInfo.getGivenName(), userDTO.getFirstName())); - userDTO.setLastName(getValueOrDefault(userClaimsInfo.getFamilyName(), userDTO.getLastName())); - userDTO.setEmailAddress(getValueOrDefault(userClaimsInfo.getEmailAddress(), userDTO.getEmailAddress())); + updateUserDTO(userClaimsInfo, provideMissingClaimsEnabled); AuthenticatedUserDisplayInfo userDisplayInfo = new AuthenticatedUserDisplayInfo( userDTO.getFirstName(), @@ -55,7 +53,7 @@ protected void executeImpl(CommandContext ctxt) throws CommandException { userDTO.getPosition() != null ? userDTO.getPosition() : "" ); - Map fieldErrors = validateUserFields(ctxt); + Map fieldErrors = validateUserFields(ctxt, provideMissingClaimsEnabled); if (!fieldErrors.isEmpty()) { throw new InvalidFieldsCommandException( BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.invalidFields"), @@ -71,19 +69,34 @@ protected void executeImpl(CommandContext ctxt) throws CommandException { } } + private void updateUserDTO(UserInfo userClaimsInfo, boolean provideMissingClaimsEnabled) { + if (provideMissingClaimsEnabled) { + // Update with available OIDC claims, keep existing values if claims are absent + userDTO.setUsername(getValueOrDefault(userClaimsInfo.getPreferredUsername(), userDTO.getUsername())); + userDTO.setFirstName(getValueOrDefault(userClaimsInfo.getGivenName(), userDTO.getFirstName())); + userDTO.setLastName(getValueOrDefault(userClaimsInfo.getFamilyName(), userDTO.getLastName())); + userDTO.setEmailAddress(getValueOrDefault(userClaimsInfo.getEmailAddress(), userDTO.getEmailAddress())); + } else { + // Always use the claims from the IdP provider + userDTO.setUsername(userClaimsInfo.getPreferredUsername()); + userDTO.setFirstName(userClaimsInfo.getGivenName()); + userDTO.setLastName(userClaimsInfo.getFamilyName()); + userDTO.setEmailAddress(userClaimsInfo.getEmailAddress()); + } + } + private String getValueOrDefault(String oidcValue, String dtoValue) { return (oidcValue == null || oidcValue.isEmpty()) ? dtoValue : oidcValue; } - private Map validateUserFields(CommandContext ctxt) { + private Map validateUserFields(CommandContext ctxt, boolean provideMissingClaimsEnabled) { Map fieldErrors = new HashMap<>(); validateTermsAccepted(fieldErrors); - validateEmailAddress(ctxt, fieldErrors); - validateUsername(ctxt, fieldErrors); - - validateRequiredField("firstName", userDTO.getFirstName(), "registerOidcUserCommand.errors.firstNameFieldRequired", fieldErrors); - validateRequiredField("lastName", userDTO.getLastName(), "registerOidcUserCommand.errors.lastNameFieldRequired", fieldErrors); + validateField(fieldErrors, "emailAddress", userDTO.getEmailAddress(), ctxt, provideMissingClaimsEnabled); + validateField(fieldErrors, "username", userDTO.getUsername(), ctxt, provideMissingClaimsEnabled); + validateField(fieldErrors, "firstName", userDTO.getFirstName(), ctxt, provideMissingClaimsEnabled); + validateField(fieldErrors, "lastName", userDTO.getLastName(), ctxt, provideMissingClaimsEnabled); return fieldErrors; } @@ -94,35 +107,23 @@ private void validateTermsAccepted(Map fieldErrors) { } } - private void validateEmailAddress(CommandContext ctxt, Map fieldErrors) { - String emailAddress = userDTO.getEmailAddress(); - if (emailAddress == null || emailAddress.isEmpty()) { - fieldErrors.put("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailFieldRequired")); - } else if (isEmailInUse(ctxt, emailAddress)) { - fieldErrors.put("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailAddressInUse")); - } - } - - private void validateUsername(CommandContext ctxt, Map fieldErrors) { - String username = userDTO.getUsername(); - if (username == null || username.isEmpty()) { - fieldErrors.put("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.usernameFieldRequired")); - } else if (isUsernameInUse(ctxt, username)) { - fieldErrors.put("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.usernameInUse")); - } - } - - private void validateRequiredField(String fieldName, String fieldValue, String bundleKey, Map fieldErrors) { + private void validateField(Map fieldErrors, String fieldName, String fieldValue, CommandContext ctxt, boolean provideMissingClaimsEnabled) { if (fieldValue == null || fieldValue.isEmpty()) { - fieldErrors.put(fieldName, BundleUtil.getStringFromBundle(bundleKey)); + String errorKey = provideMissingClaimsEnabled ? + "registerOidcUserCommand.errors.provideMissingClaimsEnabled." + fieldName + "FieldRequired" : + "registerOidcUserCommand.errors.provideMissingClaimsDisabled." + fieldName + "FieldRequired"; + fieldErrors.put(fieldName, BundleUtil.getStringFromBundle(errorKey)); + } else if (isFieldInUse(ctxt, fieldName, fieldValue)) { + fieldErrors.put(fieldName, BundleUtil.getStringFromBundle("registerOidcUserCommand.errors." + fieldName + "InUse")); } } - private boolean isEmailInUse(CommandContext ctxt, String emailAddress) { - return ctxt.authentication().getAuthenticatedUserByEmail(emailAddress) != null; - } - - private boolean isUsernameInUse(CommandContext ctxt, String username) { - return ctxt.authentication().getAuthenticatedUser(username) != null; + private boolean isFieldInUse(CommandContext ctxt, String fieldName, String value) { + if ("emailAddress".equals(fieldName)) { + return ctxt.authentication().getAuthenticatedUserByEmail(value) != null; + } else if ("username".equals(fieldName)) { + return ctxt.authentication().getAuthenticatedUser(value) != null; + } + return false; } } diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index 9ea87440535..e2fc48054e6 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3072,10 +3072,14 @@ users.api.userRegistered=User registered. registerOidcUserCommand.errors.userAlreadyRegisteredWithToken=User is already registered with this token. registerOidcUserCommand.errors.invalidFields=The provided fields are invalid for registering a new user. registerOidcUserCommand.errors.userShouldAcceptTerms=Terms should be accepted. -registerOidcUserCommand.errors.emailFieldRequired=It is required to include an emailAddress field in the request JSON for registering the user. -registerOidcUserCommand.errors.usernameFieldRequired=It is required to include a username field in the request JSON for registering the user. -registerOidcUserCommand.errors.firstNameFieldRequired=It is required to include a firstName field in the request JSON for registering the user. -registerOidcUserCommand.errors.lastNameFieldRequired=It is required to include a lastName field in the request JSON for registering the user. +registerOidcUserCommand.errors.provideMissingClaimsEnabled.emailAddressFieldRequired=It is required to include an emailAddress field in the request JSON for registering the user. +registerOidcUserCommand.errors.provideMissingClaimsDisabled.emailAddressFieldRequired=The OIDC identity provider does not provide the user claim 'email', which is required for user registration. Please contact your identity provider. +registerOidcUserCommand.errors.provideMissingClaimsEnabled.usernameFieldRequired=It is required to include a username field in the request JSON for registering the user. +registerOidcUserCommand.errors.provideMissingClaimsDisabled.usernameFieldRequired=The OIDC identity provider does not provide the user claim 'preferred_username', which is required for user registration. Please contact your identity provider. +registerOidcUserCommand.errors.provideMissingClaimsEnabled.firstNameFieldRequired=It is required to include a firstName field in the request JSON for registering the user. +registerOidcUserCommand.errors.provideMissingClaimsDisabled.firstNameFieldRequired=The OIDC identity provider does not provide the user claim 'given_name', which is required for user registration. Please contact your identity provider. +registerOidcUserCommand.errors.provideMissingClaimsEnabled.lastNameFieldRequired=It is required to include a lastName field in the request JSON for registering the user. +registerOidcUserCommand.errors.provideMissingClaimsDisabled.lastNameFieldRequired=The OIDC identity provider does not provide the user claim 'family_name', which is required for user registration. Please contact your identity provider. registerOidcUserCommand.errors.emailAddressInUse=Email already in use. registerOidcUserCommand.errors.usernameInUse=Username already in use. From 047a14c48e0a6a29cf844190ac43dd235e4b842b Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 15 Nov 2024 13:02:24 +0000 Subject: [PATCH 37/62] Fixed: RegisterOIDCUserCommandTest --- .../impl/RegisterOIDCUserCommandTest.java | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java index bd9edf150f6..30fc7687c55 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java @@ -13,7 +13,10 @@ import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; import edu.harvard.iq.dataverse.engine.command.exception.InvalidFieldsCommandException; import edu.harvard.iq.dataverse.engine.command.exception.PermissionException; +import edu.harvard.iq.dataverse.settings.JvmSettings; import edu.harvard.iq.dataverse.util.BundleUtil; +import edu.harvard.iq.dataverse.util.testing.JvmSetting; +import edu.harvard.iq.dataverse.util.testing.LocalJvmSettings; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; @@ -25,6 +28,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.*; +@LocalJvmSettings class RegisterOIDCUserCommandTest { private static final String TEST_BEARER_TOKEN = "Bearer test"; @@ -69,7 +73,7 @@ private void setUpDefaultUserDTO() { } @Test - public void execute_unacceptedTerms_availableEmailAndUsername() throws AuthorizationException { + public void execute_completedUserDTOWithUnacceptedTerms_provideMissingClaimsDisabled() throws AuthorizationException { userDTO.setTermsAccepted(false); when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(null); when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(null); @@ -81,13 +85,41 @@ public void execute_unacceptedTerms_availableEmailAndUsername() throws Authoriza InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; assertThat(ex.getFieldErrors()) .containsEntry("termsAccepted", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms")) - .doesNotContainEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailAddressInUse")) - .doesNotContainEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.usernameInUse")); + .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.emailAddressFieldRequired")) + .containsEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.usernameFieldRequired")) + .containsEntry("firstName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.firstNameFieldRequired")) + .containsEntry("lastName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.lastNameFieldRequired")); }); } @Test - public void execute_acceptedTerms_availableEmailAndUsername() throws AuthorizationException { + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") + public void execute_uncompletedUserDTOWithUnacceptedTerms_provideMissingClaimsEnabled() throws AuthorizationException { + userDTO.setTermsAccepted(false); + userDTO.setEmailAddress(null); + userDTO.setUsername(null); + userDTO.setFirstName(null); + userDTO.setLastName(null); + when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(null); + when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(null); + when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); + + assertThatThrownBy(() -> sut.execute(context)) + .isInstanceOf(InvalidFieldsCommandException.class) + .satisfies(exception -> { + InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; + assertThat(ex.getFieldErrors()) + .containsEntry("termsAccepted", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms")) + .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.emailAddressFieldRequired")) + .containsEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.usernameFieldRequired")) + .containsEntry("firstName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.firstNameFieldRequired")) + .containsEntry("lastName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.lastNameFieldRequired")); + }); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") + public void execute_acceptedTerms_unavailableEmailAndUsername_provideMissingClaimsEnabled() throws AuthorizationException { when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(existingTestUser); when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(existingTestUser); when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); @@ -130,7 +162,8 @@ void execute_throwsIllegalCommandException_ifUserAlreadyRegisteredWithToken() th } @Test - void execute_happyPath_withoutAffiliationAndPosition() throws AuthorizationException, CommandException { + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") + void execute_happyPath_withoutAffiliationAndPosition_provideMissingClaimsEnabled() throws AuthorizationException, CommandException { when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); sut.execute(context); @@ -150,7 +183,8 @@ void execute_happyPath_withoutAffiliationAndPosition() throws AuthorizationExcep } @Test - void execute_happyPath_withAffiliationAndPosition() throws AuthorizationException, CommandException { + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") + void execute_happyPath_withAffiliationAndPosition_provideMissingClaimsEnabled() throws AuthorizationException, CommandException { userDTO.setPosition("test position"); userDTO.setAffiliation("test affiliation"); From cc86a8307405344bcd1881a42d3d7a8f8ab26b5a Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 15 Nov 2024 13:14:48 +0000 Subject: [PATCH 38/62] Added: explanatory comment tweak --- .../dataverse/engine/command/impl/RegisterOIDCUserCommand.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java index 57bf7832f62..e580c1ad7cc 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java @@ -77,7 +77,7 @@ private void updateUserDTO(UserInfo userClaimsInfo, boolean provideMissingClaims userDTO.setLastName(getValueOrDefault(userClaimsInfo.getFamilyName(), userDTO.getLastName())); userDTO.setEmailAddress(getValueOrDefault(userClaimsInfo.getEmailAddress(), userDTO.getEmailAddress())); } else { - // Always use the claims from the IdP provider + // Always use the claims provided by the OIDC provider, regardless of whether they are null or not userDTO.setUsername(userClaimsInfo.getPreferredUsername()); userDTO.setFirstName(userClaimsInfo.getGivenName()); userDTO.setLastName(userClaimsInfo.getFamilyName()); From c3de7d735cdebcbbec69511928369683b9c7c5fa Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 15 Nov 2024 13:27:42 +0000 Subject: [PATCH 39/62] Added: DATAVERSE_FEATURE_API_BEARER_AUTH_PROVIDE_MISSING_CLAIMS enabled in docker-compose-dev --- docker-compose-dev.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 384b70b7a7b..3f5cae1b263 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -17,6 +17,7 @@ services: SKIP_DEPLOY: "${SKIP_DEPLOY}" DATAVERSE_JSF_REFRESH_PERIOD: "1" DATAVERSE_FEATURE_API_BEARER_AUTH: "1" + DATAVERSE_FEATURE_API_BEARER_AUTH_PROVIDE_MISSING_CLAIMS: "1" DATAVERSE_MAIL_SYSTEM_EMAIL: "dataverse@localhost" DATAVERSE_MAIL_MTA_HOST: "smtp" DATAVERSE_AUTH_OIDC_ENABLED: "1" From 25cdf98d2cba0dc672161f8cafa8d363c65c8c10 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 15 Nov 2024 13:28:00 +0000 Subject: [PATCH 40/62] Fixed: UsersIT registerOidcUser --- src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java index acd5bd658e0..cb4a2b862c9 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java @@ -627,9 +627,9 @@ public void testRegisterOIDCUser() { registerOidcUserResponse.then().assertThat() .statusCode(BAD_REQUEST.getStatusCode()) .body("message", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.invalidFields"))) - .body("fieldErrors.firstName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.firstNameFieldRequired"))) - .body("fieldErrors.lastName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.lastNameFieldRequired"))) - .body("fieldErrors.emailAddress", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailFieldRequired"))); + .body("fieldErrors.firstName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.firstNameFieldRequired"))) + .body("fieldErrors.lastName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.lastNameFieldRequired"))) + .body("fieldErrors.emailAddress", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.emailAddressFieldRequired"))); // Should register user when the Bearer token is valid and the provided User JSON contains the missing claims in the IdP registerOidcUserResponse = UtilIT.registerOidcUser( From 5d39ac187e3823e0b6ac914c7c341558e9da5d75 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 15 Nov 2024 13:56:22 +0000 Subject: [PATCH 41/62] Added: #10959 docs to auth.rst --- doc/sphinx-guides/source/api/auth.rst | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/doc/sphinx-guides/source/api/auth.rst b/doc/sphinx-guides/source/api/auth.rst index eae3bd3c969..d30d0097802 100644 --- a/doc/sphinx-guides/source/api/auth.rst +++ b/doc/sphinx-guides/source/api/auth.rst @@ -81,6 +81,29 @@ To test if bearer tokens are working, you can try something like the following ( curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/users/:me +It may happen that when you try to authenticate a user for the first time with a bearer token, it does not have an associated user account in Dataverse. In this case, it is necessary to register the user using the following endpoint: + +.. code-block:: bash + + curl -H "Authorization: Bearer $TOKEN" -X POST http://localhost:8080/api/users/register --data '{"termsAccepted":true}' + +It is essential to send a JSON that includes the property ``termsAccepted`` set to true, which indicates that you accept the terms of service of Dataverse. Otherwise, you will not be able to create an account. + +In this JSON, we can also include the fields ``position`` or ``affiliation``, in the same way as when we register a user through the Dataverse UI. These fields are optional, and if not provided, they will be persisted as empty in Dataverse. + +Beyond the ``api-bearer-auth`` feature flag, there is another flag called ``api-bearer-auth-json-claims`` that can be enabled to allow sending missing user claims in the registration JSON. This is useful when the identity provider does not supply the necessary claims. However, this flag will only be considered if the ``api-bearer-auth`` feature flag is enabled. If the latter is not enabled, the ``api-bearer-auth-json-claims`` flag will be ignored. + +With the ``api-bearer-auth`` feature flag enabled, you can include the following properties in the request JSON: + +- ``username`` +- ``firstName`` +- ``lastName`` +- ``emailAddress`` + +Note that even if they are included in the JSON, if it is possible to retrieve the corresponding claims from the identity provider, these values will be ignored and the ones from the IdP will be used instead. + +This functionality is included under a feature flag because using it may introduce potential security risks, such as user impersonation, if the identity provider does not provide an email field and the user submits an email address they do not own. + Signed URLs ----------- From a438c8a8bc6d7ea1762cb3b117d781478f4d8959 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 15 Nov 2024 14:03:28 +0000 Subject: [PATCH 42/62] Added: docs for #10959 --- doc/sphinx-guides/source/api/auth.rst | 2 +- doc/sphinx-guides/source/installation/config.rst | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/doc/sphinx-guides/source/api/auth.rst b/doc/sphinx-guides/source/api/auth.rst index d30d0097802..101e283d5b1 100644 --- a/doc/sphinx-guides/source/api/auth.rst +++ b/doc/sphinx-guides/source/api/auth.rst @@ -102,7 +102,7 @@ With the ``api-bearer-auth`` feature flag enabled, you can include the following Note that even if they are included in the JSON, if it is possible to retrieve the corresponding claims from the identity provider, these values will be ignored and the ones from the IdP will be used instead. -This functionality is included under a feature flag because using it may introduce potential security risks, such as user impersonation, if the identity provider does not provide an email field and the user submits an email address they do not own. +This functionality is included under a feature flag because using it may introduce user impersonation issues, for example if the identity provider does not provide an email field and the user submits an email address they do not own. Signed URLs ----------- diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index e3965e3cd7c..f7ccf7e1698 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -3343,6 +3343,12 @@ please find all known feature flags below. Any of these flags can be activated u * - api-session-auth - Enables API authentication via session cookie (JSESSIONID). **Caution: Enabling this feature flag exposes the installation to CSRF risks!** We expect this feature flag to be temporary (only used by frontend developers, see `#9063 `_) and for the feature to be removed in the future. - ``Off`` + * - api-bearer-auth + - Enables API authentication via Bearer Token. + - ``Off`` + * - api-bearer-auth-provide-missing-claims + - Enables sending missing user claims in the request JSON provided during OIDC user registration, when these claims are not returned by the identity provider and are required for registration. This feature only works when the feature flag ``api-bearer-auth`` is also enabled. **Caution: Enabling this feature flag exposes the installation to potential user impersonation issues.** + - ``Off`` * - avoid-expensive-solr-join - Changes the way Solr queries are constructed for public content (published Collections, Datasets and Files). It removes a very expensive Solr join on all such documents, improving overall performance, especially for large instances under heavy load. Before this feature flag is enabled, the corresponding indexing feature (see next feature flag) must be turned on and a full reindex performed (otherwise public objects are not going to be shown in search results). See :doc:`/admin/solr-search-index`. - ``Off`` From 9ba377ee05e0cc671eee14900de84cc15f50a431 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 15 Nov 2024 14:05:37 +0000 Subject: [PATCH 43/62] Fixed: doc tweak --- doc/sphinx-guides/source/api/auth.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/sphinx-guides/source/api/auth.rst b/doc/sphinx-guides/source/api/auth.rst index 101e283d5b1..ca68e507b9b 100644 --- a/doc/sphinx-guides/source/api/auth.rst +++ b/doc/sphinx-guides/source/api/auth.rst @@ -100,7 +100,7 @@ With the ``api-bearer-auth`` feature flag enabled, you can include the following - ``lastName`` - ``emailAddress`` -Note that even if they are included in the JSON, if it is possible to retrieve the corresponding claims from the identity provider, these values will be ignored and the ones from the IdP will be used instead. +Note that even if they are included in the JSON, if it is possible to retrieve the corresponding claims from the identity provider, these values will be ignored and the ones from the identity provider will be used instead. This functionality is included under a feature flag because using it may introduce user impersonation issues, for example if the identity provider does not provide an email field and the user submits an email address they do not own. From b00ac7f3f76d3f5564186b68a2255fac5a4156a4 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 15 Nov 2024 14:09:10 +0000 Subject: [PATCH 44/62] Changed: replaced version TODO with 5.14 for api-bearer-auth feature flag doc --- .../java/edu/harvard/iq/dataverse/settings/FeatureFlags.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java index 42f37034d90..b3774c3fe06 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java @@ -33,7 +33,7 @@ public enum FeatureFlags { /** * Enables API authentication via Bearer Token. * @apiNote Raise flag by setting "dataverse.feature.api-bearer-auth" - * @since Dataverse @TODO: + * @since Dataverse 5.14: */ API_BEARER_AUTH("api-bearer-auth"), /** From 73c407997b0a9972aa1e1989aef7bc4056f2128b Mon Sep 17 00:00:00 2001 From: GPortas Date: Tue, 19 Nov 2024 11:02:08 +0000 Subject: [PATCH 45/62] Changed: simpler statement in auth.rst --- doc/sphinx-guides/source/api/auth.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/sphinx-guides/source/api/auth.rst b/doc/sphinx-guides/source/api/auth.rst index ca68e507b9b..a033579d590 100644 --- a/doc/sphinx-guides/source/api/auth.rst +++ b/doc/sphinx-guides/source/api/auth.rst @@ -81,7 +81,7 @@ To test if bearer tokens are working, you can try something like the following ( curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/users/:me -It may happen that when you try to authenticate a user for the first time with a bearer token, it does not have an associated user account in Dataverse. In this case, it is necessary to register the user using the following endpoint: +To register a new user who has authenticated via an OIDC provider, the following endpoint should be used: .. code-block:: bash From f99732b2b6f7abb7a9055244a8d0e8515fbe2634 Mon Sep 17 00:00:00 2001 From: GPortas Date: Tue, 19 Nov 2024 12:22:29 +0000 Subject: [PATCH 46/62] Changed: doc tweak in auth.rst --- doc/sphinx-guides/source/api/auth.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/sphinx-guides/source/api/auth.rst b/doc/sphinx-guides/source/api/auth.rst index a033579d590..fc2aa994597 100644 --- a/doc/sphinx-guides/source/api/auth.rst +++ b/doc/sphinx-guides/source/api/auth.rst @@ -87,7 +87,7 @@ To register a new user who has authenticated via an OIDC provider, the following curl -H "Authorization: Bearer $TOKEN" -X POST http://localhost:8080/api/users/register --data '{"termsAccepted":true}' -It is essential to send a JSON that includes the property ``termsAccepted`` set to true, which indicates that you accept the terms of service of Dataverse. Otherwise, you will not be able to create an account. +It is essential to send a JSON that includes the property ``termsAccepted`` set to true, which indicates that you accept the Terms of Use of the installation. Otherwise, you will not be able to create an account. In this JSON, we can also include the fields ``position`` or ``affiliation``, in the same way as when we register a user through the Dataverse UI. These fields are optional, and if not provided, they will be persisted as empty in Dataverse. From dbfe40d59fd95e6ba4c2eda18720ef13afc16164 Mon Sep 17 00:00:00 2001 From: GPortas Date: Tue, 19 Nov 2024 13:29:34 +0000 Subject: [PATCH 47/62] Refactor: registerOidcUserCommand Bundle strings --- .../command/impl/RegisterOIDCUserCommand.java | 7 ++++--- src/main/java/propertyFiles/Bundle.properties | 10 ++-------- .../edu/harvard/iq/dataverse/api/UsersIT.java | 6 +++--- .../impl/RegisterOIDCUserCommandTest.java | 18 ++++++++++-------- 4 files changed, 19 insertions(+), 22 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java index e580c1ad7cc..0fb8b5de848 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java @@ -16,6 +16,7 @@ import edu.harvard.iq.dataverse.util.BundleUtil; import java.util.HashMap; +import java.util.List; import java.util.Map; @RequiredPermissions({}) @@ -110,9 +111,9 @@ private void validateTermsAccepted(Map fieldErrors) { private void validateField(Map fieldErrors, String fieldName, String fieldValue, CommandContext ctxt, boolean provideMissingClaimsEnabled) { if (fieldValue == null || fieldValue.isEmpty()) { String errorKey = provideMissingClaimsEnabled ? - "registerOidcUserCommand.errors.provideMissingClaimsEnabled." + fieldName + "FieldRequired" : - "registerOidcUserCommand.errors.provideMissingClaimsDisabled." + fieldName + "FieldRequired"; - fieldErrors.put(fieldName, BundleUtil.getStringFromBundle(errorKey)); + "registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired" : + "registerOidcUserCommand.errors.provideMissingClaimsDisabled.fieldRequired"; + fieldErrors.put(fieldName, BundleUtil.getStringFromBundle(errorKey, List.of(fieldName))); } else if (isFieldInUse(ctxt, fieldName, fieldValue)) { fieldErrors.put(fieldName, BundleUtil.getStringFromBundle("registerOidcUserCommand.errors." + fieldName + "InUse")); } diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index e2fc48054e6..f814e08c49e 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3072,14 +3072,8 @@ users.api.userRegistered=User registered. registerOidcUserCommand.errors.userAlreadyRegisteredWithToken=User is already registered with this token. registerOidcUserCommand.errors.invalidFields=The provided fields are invalid for registering a new user. registerOidcUserCommand.errors.userShouldAcceptTerms=Terms should be accepted. -registerOidcUserCommand.errors.provideMissingClaimsEnabled.emailAddressFieldRequired=It is required to include an emailAddress field in the request JSON for registering the user. -registerOidcUserCommand.errors.provideMissingClaimsDisabled.emailAddressFieldRequired=The OIDC identity provider does not provide the user claim 'email', which is required for user registration. Please contact your identity provider. -registerOidcUserCommand.errors.provideMissingClaimsEnabled.usernameFieldRequired=It is required to include a username field in the request JSON for registering the user. -registerOidcUserCommand.errors.provideMissingClaimsDisabled.usernameFieldRequired=The OIDC identity provider does not provide the user claim 'preferred_username', which is required for user registration. Please contact your identity provider. -registerOidcUserCommand.errors.provideMissingClaimsEnabled.firstNameFieldRequired=It is required to include a firstName field in the request JSON for registering the user. -registerOidcUserCommand.errors.provideMissingClaimsDisabled.firstNameFieldRequired=The OIDC identity provider does not provide the user claim 'given_name', which is required for user registration. Please contact your identity provider. -registerOidcUserCommand.errors.provideMissingClaimsEnabled.lastNameFieldRequired=It is required to include a lastName field in the request JSON for registering the user. -registerOidcUserCommand.errors.provideMissingClaimsDisabled.lastNameFieldRequired=The OIDC identity provider does not provide the user claim 'family_name', which is required for user registration. Please contact your identity provider. +registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired=It is required to include the field {0} in the request JSON for registering the user. +registerOidcUserCommand.errors.provideMissingClaimsDisabled.fieldRequired=The OIDC identity provider does not provide the user claim {0}, which is required for user registration. Please contact an administrator. registerOidcUserCommand.errors.emailAddressInUse=Email already in use. registerOidcUserCommand.errors.usernameInUse=Username already in use. diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java index cb4a2b862c9..bc9b7f756f7 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java @@ -627,9 +627,9 @@ public void testRegisterOIDCUser() { registerOidcUserResponse.then().assertThat() .statusCode(BAD_REQUEST.getStatusCode()) .body("message", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.invalidFields"))) - .body("fieldErrors.firstName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.firstNameFieldRequired"))) - .body("fieldErrors.lastName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.lastNameFieldRequired"))) - .body("fieldErrors.emailAddress", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.emailAddressFieldRequired"))); + .body("fieldErrors.firstName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired", List.of("firstName")))) + .body("fieldErrors.lastName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired", List.of("lastName")))) + .body("fieldErrors.emailAddress", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired", List.of("emailAddress")))); // Should register user when the Bearer token is valid and the provided User JSON contains the missing claims in the IdP registerOidcUserResponse = UtilIT.registerOidcUser( diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java index 30fc7687c55..5ee5bf443fa 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java @@ -23,6 +23,8 @@ import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import java.util.List; + import static edu.harvard.iq.dataverse.mocks.MocksFactory.makeRequest; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -85,10 +87,10 @@ public void execute_completedUserDTOWithUnacceptedTerms_provideMissingClaimsDisa InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; assertThat(ex.getFieldErrors()) .containsEntry("termsAccepted", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms")) - .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.emailAddressFieldRequired")) - .containsEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.usernameFieldRequired")) - .containsEntry("firstName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.firstNameFieldRequired")) - .containsEntry("lastName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.lastNameFieldRequired")); + .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.fieldRequired", List.of("emailAddress"))) + .containsEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.fieldRequired", List.of("username"))) + .containsEntry("firstName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.fieldRequired", List.of("firstName"))) + .containsEntry("lastName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.fieldRequired", List.of("lastName"))); }); } @@ -110,10 +112,10 @@ public void execute_uncompletedUserDTOWithUnacceptedTerms_provideMissingClaimsEn InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; assertThat(ex.getFieldErrors()) .containsEntry("termsAccepted", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms")) - .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.emailAddressFieldRequired")) - .containsEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.usernameFieldRequired")) - .containsEntry("firstName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.firstNameFieldRequired")) - .containsEntry("lastName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.lastNameFieldRequired")); + .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired", List.of("emailAddress"))) + .containsEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired", List.of("username"))) + .containsEntry("firstName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired", List.of("firstName"))) + .containsEntry("lastName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired", List.of("lastName"))); }); } From 4ae119c0e3b43a597eead60f5e27bc733f71d7f2 Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 20 Nov 2024 11:12:45 +0000 Subject: [PATCH 48/62] Changed: throwing an error when registering an OIDC user and attempting to set JSON properties that conflict with existing claims in the IdP --- .../command/impl/RegisterOIDCUserCommand.java | 94 +++++++++++++------ src/main/java/propertyFiles/Bundle.properties | 1 + .../edu/harvard/iq/dataverse/api/UsersIT.java | 16 ++++ .../impl/RegisterOIDCUserCommandTest.java | 27 ++++++ 4 files changed, 110 insertions(+), 28 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java index 0fb8b5de848..3c4bf4f097b 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java @@ -22,6 +22,12 @@ @RequiredPermissions({}) public class RegisterOIDCUserCommand extends AbstractVoidCommand { + private static final String FIELD_USERNAME = "username"; + private static final String FIELD_FIRST_NAME = "firstName"; + private static final String FIELD_LAST_NAME = "lastName"; + private static final String FIELD_EMAIL_ADDRESS = "emailAddress"; + private static final String FIELD_TERMS_ACCEPTED = "termsAccepted"; + private final String bearerToken; private final UserDTO userDTO; @@ -54,14 +60,7 @@ protected void executeImpl(CommandContext ctxt) throws CommandException { userDTO.getPosition() != null ? userDTO.getPosition() : "" ); - Map fieldErrors = validateUserFields(ctxt, provideMissingClaimsEnabled); - if (!fieldErrors.isEmpty()) { - throw new InvalidFieldsCommandException( - BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.invalidFields"), - this, - fieldErrors - ); - } + validateUserFields(ctxt, provideMissingClaimsEnabled); ctxt.authentication().createAuthenticatedUser(userRecordIdentifier, userDTO.getUsername(), userDisplayInfo, true); @@ -70,19 +69,58 @@ protected void executeImpl(CommandContext ctxt) throws CommandException { } } - private void updateUserDTO(UserInfo userClaimsInfo, boolean provideMissingClaimsEnabled) { + private void updateUserDTO(UserInfo userClaimsInfo, boolean provideMissingClaimsEnabled) throws InvalidFieldsCommandException { if (provideMissingClaimsEnabled) { - // Update with available OIDC claims, keep existing values if claims are absent - userDTO.setUsername(getValueOrDefault(userClaimsInfo.getPreferredUsername(), userDTO.getUsername())); - userDTO.setFirstName(getValueOrDefault(userClaimsInfo.getGivenName(), userDTO.getFirstName())); - userDTO.setLastName(getValueOrDefault(userClaimsInfo.getFamilyName(), userDTO.getLastName())); - userDTO.setEmailAddress(getValueOrDefault(userClaimsInfo.getEmailAddress(), userDTO.getEmailAddress())); + Map fieldErrors = validateConflictingClaims(userClaimsInfo); + throwInvalidFieldsCommandExceptionIfErrorsExist(fieldErrors); + updateUserDTOWithClaims(userClaimsInfo); } else { - // Always use the claims provided by the OIDC provider, regardless of whether they are null or not - userDTO.setUsername(userClaimsInfo.getPreferredUsername()); - userDTO.setFirstName(userClaimsInfo.getGivenName()); - userDTO.setLastName(userClaimsInfo.getFamilyName()); - userDTO.setEmailAddress(userClaimsInfo.getEmailAddress()); + overwriteUserDTOWithClaims(userClaimsInfo); + } + } + + private Map validateConflictingClaims(UserInfo userClaimsInfo) { + Map fieldErrors = new HashMap<>(); + + addFieldErrorIfConflict(FIELD_USERNAME, userClaimsInfo.getPreferredUsername(), userDTO.getUsername(), fieldErrors); + addFieldErrorIfConflict(FIELD_FIRST_NAME, userClaimsInfo.getGivenName(), userDTO.getFirstName(), fieldErrors); + addFieldErrorIfConflict(FIELD_LAST_NAME, userClaimsInfo.getFamilyName(), userDTO.getLastName(), fieldErrors); + addFieldErrorIfConflict(FIELD_EMAIL_ADDRESS, userClaimsInfo.getEmailAddress(), userDTO.getEmailAddress(), fieldErrors); + + return fieldErrors; + } + + private void addFieldErrorIfConflict(String fieldName, String claimValue, String existingValue, Map fieldErrors) { + if (claimValue != null && existingValue != null && !claimValue.equals(existingValue)) { + String errorMessage = BundleUtil.getStringFromBundle( + "registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", + List.of(fieldName) + ); + fieldErrors.put(fieldName, errorMessage); + } + } + + private void updateUserDTOWithClaims(UserInfo userClaimsInfo) { + userDTO.setUsername(getValueOrDefault(userClaimsInfo.getPreferredUsername(), userDTO.getUsername())); + userDTO.setFirstName(getValueOrDefault(userClaimsInfo.getGivenName(), userDTO.getFirstName())); + userDTO.setLastName(getValueOrDefault(userClaimsInfo.getFamilyName(), userDTO.getLastName())); + userDTO.setEmailAddress(getValueOrDefault(userClaimsInfo.getEmailAddress(), userDTO.getEmailAddress())); + } + + private void overwriteUserDTOWithClaims(UserInfo userClaimsInfo) { + userDTO.setUsername(userClaimsInfo.getPreferredUsername()); + userDTO.setFirstName(userClaimsInfo.getGivenName()); + userDTO.setLastName(userClaimsInfo.getFamilyName()); + userDTO.setEmailAddress(userClaimsInfo.getEmailAddress()); + } + + private void throwInvalidFieldsCommandExceptionIfErrorsExist(Map fieldErrors) throws InvalidFieldsCommandException { + if (!fieldErrors.isEmpty()) { + throw new InvalidFieldsCommandException( + BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.invalidFields"), + this, + fieldErrors + ); } } @@ -90,21 +128,21 @@ private String getValueOrDefault(String oidcValue, String dtoValue) { return (oidcValue == null || oidcValue.isEmpty()) ? dtoValue : oidcValue; } - private Map validateUserFields(CommandContext ctxt, boolean provideMissingClaimsEnabled) { + private void validateUserFields(CommandContext ctxt, boolean provideMissingClaimsEnabled) throws InvalidFieldsCommandException { Map fieldErrors = new HashMap<>(); validateTermsAccepted(fieldErrors); - validateField(fieldErrors, "emailAddress", userDTO.getEmailAddress(), ctxt, provideMissingClaimsEnabled); - validateField(fieldErrors, "username", userDTO.getUsername(), ctxt, provideMissingClaimsEnabled); - validateField(fieldErrors, "firstName", userDTO.getFirstName(), ctxt, provideMissingClaimsEnabled); - validateField(fieldErrors, "lastName", userDTO.getLastName(), ctxt, provideMissingClaimsEnabled); + validateField(fieldErrors, FIELD_EMAIL_ADDRESS, userDTO.getEmailAddress(), ctxt, provideMissingClaimsEnabled); + validateField(fieldErrors, FIELD_USERNAME, userDTO.getUsername(), ctxt, provideMissingClaimsEnabled); + validateField(fieldErrors, FIELD_FIRST_NAME, userDTO.getFirstName(), ctxt, provideMissingClaimsEnabled); + validateField(fieldErrors, FIELD_LAST_NAME, userDTO.getLastName(), ctxt, provideMissingClaimsEnabled); - return fieldErrors; + throwInvalidFieldsCommandExceptionIfErrorsExist(fieldErrors); } private void validateTermsAccepted(Map fieldErrors) { if (!userDTO.isTermsAccepted()) { - fieldErrors.put("termsAccepted", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms")); + fieldErrors.put(FIELD_TERMS_ACCEPTED, BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms")); } } @@ -120,9 +158,9 @@ private void validateField(Map fieldErrors, String fieldName, St } private boolean isFieldInUse(CommandContext ctxt, String fieldName, String value) { - if ("emailAddress".equals(fieldName)) { + if (FIELD_EMAIL_ADDRESS.equals(fieldName)) { return ctxt.authentication().getAuthenticatedUserByEmail(value) != null; - } else if ("username".equals(fieldName)) { + } else if (FIELD_USERNAME.equals(fieldName)) { return ctxt.authentication().getAuthenticatedUser(value) != null; } return false; diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index f814e08c49e..62b1c3ed3cd 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3072,6 +3072,7 @@ users.api.userRegistered=User registered. registerOidcUserCommand.errors.userAlreadyRegisteredWithToken=User is already registered with this token. registerOidcUserCommand.errors.invalidFields=The provided fields are invalid for registering a new user. registerOidcUserCommand.errors.userShouldAcceptTerms=Terms should be accepted. +registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider=Unable to set {0} because it conflicts with an existing claim from the OIDC identity provider. registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired=It is required to include the field {0} in the request JSON for registering the user. registerOidcUserCommand.errors.provideMissingClaimsDisabled.fieldRequired=The OIDC identity provider does not provide the user claim {0}, which is required for user registration. Please contact an administrator. registerOidcUserCommand.errors.emailAddressInUse=Email already in use. diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java index bc9b7f756f7..eb78a216626 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java @@ -660,6 +660,22 @@ public void testRegisterOIDCUser() { .statusCode(FORBIDDEN.getStatusCode()) .body("message", equalTo("User is already registered with this token.")); + // Should return an error when the Bearer token is valid and attempting to set JSON properties that conflict with existing claims in the IdP + registerOidcUserResponse = UtilIT.registerOidcUser( + "{" + + "\"firstName\":\"testFirstName\"," + + "\"lastName\":\"testLastName\"," + + "\"emailAddress\":\"" + UUID.randomUUID().toString().substring(0, 8) + "@dataverse.org\"," + + "\"termsAccepted\":true" + + "}", + "Bearer " + userWithClaimsAccessToken + ); + registerOidcUserResponse.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("fieldErrors.firstName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("firstName")))) + .body("fieldErrors.lastName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("lastName")))) + .body("fieldErrors.emailAddress", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("emailAddress")))); + // Should register user when the Bearer token is valid and all required claims are present in the IdP, requiring only minimal data in the User JSON registerOidcUserResponse = UtilIT.registerOidcUser( "{" diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java index 5ee5bf443fa..bb6d2e609ae 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java @@ -207,4 +207,31 @@ void execute_happyPath_withAffiliationAndPosition_provideMissingClaimsEnabled() eq(true) ); } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") + void execute_conflictingClaims_provideMissingClaimsEnabled() throws AuthorizationException { + when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); + + when(userInfoMock.getPreferredUsername()).thenReturn("conflictingUsername"); + when(userInfoMock.getGivenName()).thenReturn("conflictingFirstName"); + when(userInfoMock.getFamilyName()).thenReturn("conflictingLastName"); + when(userInfoMock.getEmailAddress()).thenReturn("conflicting@example.com"); + + userDTO.setUsername("username"); + userDTO.setFirstName("FirstName"); + userDTO.setLastName("LastName"); + userDTO.setEmailAddress("user@example.com"); + + assertThatThrownBy(() -> sut.execute(context)) + .isInstanceOf(InvalidFieldsCommandException.class) + .satisfies(exception -> { + InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; + assertThat(ex.getFieldErrors()) + .containsEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("username"))) + .containsEntry("firstName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("firstName"))) + .containsEntry("lastName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("lastName"))) + .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("emailAddress"))); + }); + } } From 335e40a50340bb5d74e25e17dc86926a36657a79 Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 20 Nov 2024 11:40:32 +0000 Subject: [PATCH 49/62] Changed: doc tweak for api-bearer-auth-json-claims --- doc/sphinx-guides/source/api/auth.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/sphinx-guides/source/api/auth.rst b/doc/sphinx-guides/source/api/auth.rst index fc2aa994597..51234ad08bc 100644 --- a/doc/sphinx-guides/source/api/auth.rst +++ b/doc/sphinx-guides/source/api/auth.rst @@ -100,7 +100,7 @@ With the ``api-bearer-auth`` feature flag enabled, you can include the following - ``lastName`` - ``emailAddress`` -Note that even if they are included in the JSON, if it is possible to retrieve the corresponding claims from the identity provider, these values will be ignored and the ones from the identity provider will be used instead. +If properties are provided in the JSON, but corresponding claims already exist in the identity provider, an error will be thrown, outlining the conflicting properties. This functionality is included under a feature flag because using it may introduce user impersonation issues, for example if the identity provider does not provide an email field and the user submits an email address they do not own. From 4ca607025e20a264260b5c18f40d1da177e072fe Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 20 Nov 2024 14:46:04 +0000 Subject: [PATCH 50/62] Refactor: using OAuth2UserRecord instead of OIDCUserInfo --- .../AuthenticationServiceBean.java | 18 +- .../oauth2/oidc/OIDCAuthProvider.java | 42 +--- .../command/impl/RegisterOIDCUserCommand.java | 48 ++-- .../AuthenticationServiceBeanTest.java | 81 ++++--- .../impl/RegisterOIDCUserCommandTest.java | 218 +++++++++++------- 5 files changed, 220 insertions(+), 187 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java index 3d46af4f8cf..f5c354defeb 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -11,6 +11,7 @@ import edu.harvard.iq.dataverse.UserServiceBean; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2Exception; +import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2UserRecord; import edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthProvider; import edu.harvard.iq.dataverse.search.IndexServiceBean; import edu.harvard.iq.dataverse.actionlogging.ActionLogRecord; @@ -987,18 +988,18 @@ public ApiToken getValidApiTokenForUser(User user) { public AuthenticatedUser lookupUserByOIDCBearerToken(String bearerToken) throws AuthorizationException { // TODO: Get the identifier from an invalidating cache to avoid lookup bursts of the same token. // Tokens in the cache should be removed after some (configurable) time. - OIDCUserInfo oidcUserInfo = verifyOIDCBearerTokenAndGetUserIdentifier(bearerToken); - return lookupUser(oidcUserInfo.getUserRecordIdentifier()); + OAuth2UserRecord oAuth2UserRecord = verifyOIDCBearerTokenAndGetOAuth2UserRecord(bearerToken); + return lookupUser(oAuth2UserRecord.getUserRecordIdentifier()); } /** - * Verifies the given OIDC bearer token and retrieves the corresponding OIDC user info. + * Verifies the given OIDC bearer token and retrieves the corresponding OAuth2UserRecord. * * @param bearerToken The OIDC bearer token. * @return An {@link OIDCUserInfo} containing the user's identifier and user info. * @throws AuthorizationException If the token is invalid or if no OIDC providers are available. */ - public OIDCUserInfo verifyOIDCBearerTokenAndGetUserIdentifier(String bearerToken) throws AuthorizationException { + public OAuth2UserRecord verifyOIDCBearerTokenAndGetOAuth2UserRecord(String bearerToken) throws AuthorizationException { try { BearerAccessToken accessToken = BearerAccessToken.parse(bearerToken); List providers = getAvailableOidcProviders(); @@ -1012,14 +1013,11 @@ public OIDCUserInfo verifyOIDCBearerTokenAndGetUserIdentifier(String bearerToken // Attempt to validate the token with each configured OIDC provider. for (OIDCAuthProvider provider : providers) { try { - // Retrieve both user identifier and user info - Optional userRecordIdentifier = provider.getUserIdentifier(accessToken); + // Retrieve OAuth2UserRecord if UserInfo is present Optional userInfo = provider.getUserInfo(accessToken); - - // If either is present, return the result - if (userRecordIdentifier.isPresent() || userInfo.isPresent()) { + if (userInfo.isPresent()) { logger.log(Level.FINE, "Bearer token detected, provider {0} confirmed validity and provided user info", provider.getId()); - return new OIDCUserInfo(userRecordIdentifier.get(), userInfo.get()); + return provider.getUserRecord(userInfo.get()); } } catch (IOException | OAuth2Exception e) { logger.log(Level.FINE, "Bearer token detected, provider " + provider.getId() + " indicates an invalid Token, skipping", e); diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java index 675e1696844..f396ebf6487 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java @@ -242,7 +242,7 @@ public OAuth2UserRecord getUserRecord(String code, String state, String redirect * @param userInfo * @return the usable user record for processing ing {@link edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2LoginBackingBean} */ - OAuth2UserRecord getUserRecord(UserInfo userInfo) { + public OAuth2UserRecord getUserRecord(UserInfo userInfo) { return new OAuth2UserRecord( this.getId(), userInfo.getSubject().getValue(), @@ -316,44 +316,4 @@ public Optional getUserInfo(BearerAccessToken accessToken) throws IOEx throw new OAuth2Exception(-1, ex.getMessage(), BundleUtil.getStringFromBundle("auth.providers.exception.userinfo", Arrays.asList(this.getTitle()))); } } - - /** - * Trades an access token for an {@link UserRecordIdentifier} (if valid). - * - * @apiNote The resulting {@link UserRecordIdentifier} may be used with - * {@link edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean#lookupUser(UserRecordIdentifier)} - * to look up an {@link edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser} from the database. - * @see edu.harvard.iq.dataverse.api.auth.BearerTokenAuthMechanism - * - * @param accessToken The token to use when requesting user information from the provider - * @return Returns an {@link UserRecordIdentifier} for a valid access token or an empty {@link Optional}. - * @throws IOException In case communication with the endpoint fails to succeed for an I/O reason - */ - public Optional getUserIdentifier(BearerAccessToken accessToken) throws IOException { - OAuth2UserRecord userRecord; - try { - // Try to retrieve with given token (throws if invalid token) - Optional userInfo = getUserInfo(accessToken); - - if (userInfo.isPresent()) { - // Take this detour to avoid code duplication and potentially hard to track conversion errors. - userRecord = getUserRecord(userInfo.get()); - } else { - // This should not happen - an error at the provider side will lead to an exception. - logger.log(Level.WARNING, - "User info retrieval from {0} returned empty optional but expected exception for token {1}.", - List.of(getId(), accessToken).toArray() - ); - return Optional.empty(); - } - } catch (OAuth2Exception e) { - logger.log(Level.FINE, - "Could not retrieve user info with token {0} at provider {1}: {2}", - List.of(accessToken, getId(), e.getMessage()).toArray()); - logger.log(Level.FINER, "Retrieval failed, details as follows: ", e); - return Optional.empty(); - } - - return Optional.of(userRecord.getUserRecordIdentifier()); - } } diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java index 3c4bf4f097b..2c94a08b088 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java @@ -1,12 +1,11 @@ package edu.harvard.iq.dataverse.engine.command.impl; -import com.nimbusds.openid.connect.sdk.claims.UserInfo; import edu.harvard.iq.dataverse.DvObject; import edu.harvard.iq.dataverse.api.dto.UserDTO; import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; -import edu.harvard.iq.dataverse.authorization.OIDCUserInfo; import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; +import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2UserRecord; import edu.harvard.iq.dataverse.engine.command.*; import edu.harvard.iq.dataverse.engine.command.exception.CommandException; import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; @@ -40,17 +39,16 @@ public RegisterOIDCUserCommand(DataverseRequest aRequest, String bearerToken, Us @Override protected void executeImpl(CommandContext ctxt) throws CommandException { try { - OIDCUserInfo oidcUserInfo = ctxt.authentication().verifyOIDCBearerTokenAndGetUserIdentifier(bearerToken); - UserRecordIdentifier userRecordIdentifier = oidcUserInfo.getUserRecordIdentifier(); + OAuth2UserRecord oAuth2UserRecord = ctxt.authentication().verifyOIDCBearerTokenAndGetOAuth2UserRecord(bearerToken); + UserRecordIdentifier userRecordIdentifier = oAuth2UserRecord.getUserRecordIdentifier(); if (ctxt.authentication().lookupUser(userRecordIdentifier) != null) { throw new IllegalCommandException(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userAlreadyRegisteredWithToken"), this); } - UserInfo userClaimsInfo = oidcUserInfo.getUserClaimsInfo(); boolean provideMissingClaimsEnabled = FeatureFlags.API_BEARER_AUTH_PROVIDE_MISSING_CLAIMS.enabled(); - updateUserDTO(userClaimsInfo, provideMissingClaimsEnabled); + updateUserDTO(oAuth2UserRecord, provideMissingClaimsEnabled); AuthenticatedUserDisplayInfo userDisplayInfo = new AuthenticatedUserDisplayInfo( userDTO.getFirstName(), @@ -69,23 +67,23 @@ protected void executeImpl(CommandContext ctxt) throws CommandException { } } - private void updateUserDTO(UserInfo userClaimsInfo, boolean provideMissingClaimsEnabled) throws InvalidFieldsCommandException { + private void updateUserDTO(OAuth2UserRecord oAuth2UserRecord, boolean provideMissingClaimsEnabled) throws InvalidFieldsCommandException { if (provideMissingClaimsEnabled) { - Map fieldErrors = validateConflictingClaims(userClaimsInfo); + Map fieldErrors = validateConflictingClaims(oAuth2UserRecord); throwInvalidFieldsCommandExceptionIfErrorsExist(fieldErrors); - updateUserDTOWithClaims(userClaimsInfo); + updateUserDTOWithClaims(oAuth2UserRecord); } else { - overwriteUserDTOWithClaims(userClaimsInfo); + overwriteUserDTOWithClaims(oAuth2UserRecord); } } - private Map validateConflictingClaims(UserInfo userClaimsInfo) { + private Map validateConflictingClaims(OAuth2UserRecord oAuth2UserRecord) { Map fieldErrors = new HashMap<>(); - addFieldErrorIfConflict(FIELD_USERNAME, userClaimsInfo.getPreferredUsername(), userDTO.getUsername(), fieldErrors); - addFieldErrorIfConflict(FIELD_FIRST_NAME, userClaimsInfo.getGivenName(), userDTO.getFirstName(), fieldErrors); - addFieldErrorIfConflict(FIELD_LAST_NAME, userClaimsInfo.getFamilyName(), userDTO.getLastName(), fieldErrors); - addFieldErrorIfConflict(FIELD_EMAIL_ADDRESS, userClaimsInfo.getEmailAddress(), userDTO.getEmailAddress(), fieldErrors); + addFieldErrorIfConflict(FIELD_USERNAME, oAuth2UserRecord.getUsername(), userDTO.getUsername(), fieldErrors); + addFieldErrorIfConflict(FIELD_FIRST_NAME, oAuth2UserRecord.getDisplayInfo().getFirstName(), userDTO.getFirstName(), fieldErrors); + addFieldErrorIfConflict(FIELD_LAST_NAME, oAuth2UserRecord.getDisplayInfo().getLastName(), userDTO.getLastName(), fieldErrors); + addFieldErrorIfConflict(FIELD_EMAIL_ADDRESS, oAuth2UserRecord.getDisplayInfo().getEmailAddress(), userDTO.getEmailAddress(), fieldErrors); return fieldErrors; } @@ -100,18 +98,18 @@ private void addFieldErrorIfConflict(String fieldName, String claimValue, String } } - private void updateUserDTOWithClaims(UserInfo userClaimsInfo) { - userDTO.setUsername(getValueOrDefault(userClaimsInfo.getPreferredUsername(), userDTO.getUsername())); - userDTO.setFirstName(getValueOrDefault(userClaimsInfo.getGivenName(), userDTO.getFirstName())); - userDTO.setLastName(getValueOrDefault(userClaimsInfo.getFamilyName(), userDTO.getLastName())); - userDTO.setEmailAddress(getValueOrDefault(userClaimsInfo.getEmailAddress(), userDTO.getEmailAddress())); + private void updateUserDTOWithClaims(OAuth2UserRecord oAuth2UserRecord) { + userDTO.setUsername(getValueOrDefault(oAuth2UserRecord.getUsername(), userDTO.getUsername())); + userDTO.setFirstName(getValueOrDefault(oAuth2UserRecord.getDisplayInfo().getFirstName(), userDTO.getFirstName())); + userDTO.setLastName(getValueOrDefault(oAuth2UserRecord.getDisplayInfo().getLastName(), userDTO.getLastName())); + userDTO.setEmailAddress(getValueOrDefault(oAuth2UserRecord.getDisplayInfo().getEmailAddress(), userDTO.getEmailAddress())); } - private void overwriteUserDTOWithClaims(UserInfo userClaimsInfo) { - userDTO.setUsername(userClaimsInfo.getPreferredUsername()); - userDTO.setFirstName(userClaimsInfo.getGivenName()); - userDTO.setLastName(userClaimsInfo.getFamilyName()); - userDTO.setEmailAddress(userClaimsInfo.getEmailAddress()); + private void overwriteUserDTOWithClaims(OAuth2UserRecord oAuth2UserRecord) { + userDTO.setUsername(oAuth2UserRecord.getUsername()); + userDTO.setFirstName(oAuth2UserRecord.getDisplayInfo().getFirstName()); + userDTO.setLastName(oAuth2UserRecord.getDisplayInfo().getLastName()); + userDTO.setEmailAddress(oAuth2UserRecord.getDisplayInfo().getEmailAddress()); } private void throwInvalidFieldsCommandExceptionIfErrorsExist(Map fieldErrors) throws InvalidFieldsCommandException { diff --git a/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java b/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java index a1e51fb3e01..56ac4eefb3d 100644 --- a/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java @@ -5,6 +5,7 @@ import com.nimbusds.openid.connect.sdk.claims.UserInfo; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2Exception; +import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2UserRecord; import edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthProvider; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.User; @@ -48,11 +49,11 @@ void testLookupUserByOIDCBearerToken_no_OIDCProvider() { } @Test - void testLookupUserByOIDCBearerToken_oneProvider_invalidToken_1() throws ParseException, IOException { + void testLookupUserByOIDCBearerToken_oneProvider_invalidToken_1() throws ParseException, OAuth2Exception, IOException { // Given a single OIDC provider that cannot find a user - OIDCAuthProvider oidcAuthProvider = mockOIDCAuthProvider("OIEDC"); + OIDCAuthProvider oidcAuthProviderStub = stubOIDCAuthProvider("OIEDC"); BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); - Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.empty()); + Mockito.when(oidcAuthProviderStub.getUserInfo(token)).thenReturn(Optional.empty()); // When invoking lookupUserByOIDCBearerToken AuthorizationException exception = assertThrows(AuthorizationException.class, @@ -63,11 +64,11 @@ void testLookupUserByOIDCBearerToken_oneProvider_invalidToken_1() throws ParseEx } @Test - void testLookupUserByOIDCBearerToken_oneProvider_invalidToken_2() throws ParseException, IOException { + void testLookupUserByOIDCBearerToken_oneProvider_invalidToken_2() throws ParseException, IOException, OAuth2Exception { // Given a single OIDC provider that throws an IOException - OIDCAuthProvider oidcAuthProvider = mockOIDCAuthProvider("OIEDC"); + OIDCAuthProvider oidcAuthProviderStub = stubOIDCAuthProvider("OIEDC"); BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); - Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenThrow(IOException.class); + Mockito.when(oidcAuthProviderStub.getUserInfo(token)).thenThrow(IOException.class); // When invoking lookupUserByOIDCBearerToken AuthorizationException exception = assertThrows(AuthorizationException.class, @@ -80,12 +81,10 @@ void testLookupUserByOIDCBearerToken_oneProvider_invalidToken_2() throws ParseEx @Test void testLookupUserByOIDCBearerToken_oneProvider_validToken() throws ParseException, IOException, AuthorizationException, OAuth2Exception { // Given a single OIDC provider that returns a valid user identifier - OIDCAuthProvider oidcAuthProvider = mockOIDCAuthProvider("OIEDC"); + setUpOIDCProviderWhichValidatesToken(); + + // Setting up an authenticated user is found AuthenticatedUser authenticatedUser = setupAuthenticatedUserQueryWithResult(new AuthenticatedUser()); - UserRecordIdentifier userInfo = new UserRecordIdentifier("OIEDC", "KEY"); - BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); - Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.of(userInfo)); - Mockito.when(oidcAuthProvider.getUserInfo(token)).thenReturn(Optional.of(Mockito.mock(UserInfo.class))); // When invoking lookupUserByOIDCBearerToken User actualUser = sut.lookupUserByOIDCBearerToken(TEST_BEARER_TOKEN); @@ -96,13 +95,11 @@ void testLookupUserByOIDCBearerToken_oneProvider_validToken() throws ParseExcept @Test void testLookupUserByOIDCBearerToken_oneProvider_validToken_noAccount() throws ParseException, IOException, AuthorizationException, OAuth2Exception { - // Given a single OIDC provider with a valid user identifier but no account exists - OIDCAuthProvider oidcAuthProvider = mockOIDCAuthProvider("OIEDC"); + // Given a single OIDC provider that returns a valid user identifier + setUpOIDCProviderWhichValidatesToken(); + + // Setting up an authenticated user is not found setupAuthenticatedUserQueryWithNoResult(); - UserRecordIdentifier userInfo = new UserRecordIdentifier("OIEDC", "KEY"); - BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); - Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.of(userInfo)); - Mockito.when(oidcAuthProvider.getUserInfo(token)).thenReturn(Optional.of(Mockito.mock(UserInfo.class))); // When invoking lookupUserByOIDCBearerToken User actualUser = sut.lookupUserByOIDCBearerToken(TEST_BEARER_TOKEN); @@ -111,25 +108,45 @@ void testLookupUserByOIDCBearerToken_oneProvider_validToken_noAccount() throws P assertNull(actualUser); } - private OIDCAuthProvider mockOIDCAuthProvider(String providerID) { - OIDCAuthProvider oidcAuthProvider = Mockito.mock(OIDCAuthProvider.class); - Mockito.when(oidcAuthProvider.getId()).thenReturn(providerID); - Mockito.when(sut.authProvidersRegistrationService.getAuthenticationProvidersMap()).thenReturn(Map.of(providerID, oidcAuthProvider)); - return oidcAuthProvider; - } - private AuthenticatedUser setupAuthenticatedUserQueryWithResult(AuthenticatedUser authenticatedUser) { - TypedQuery queryMock = Mockito.mock(TypedQuery.class); - AuthenticatedUserLookup lookupMock = Mockito.mock(AuthenticatedUserLookup.class); - Mockito.when(lookupMock.getAuthenticatedUser()).thenReturn(authenticatedUser); - Mockito.when(queryMock.getSingleResult()).thenReturn(lookupMock); - Mockito.when(sut.em.createNamedQuery("AuthenticatedUserLookup.findByAuthPrvID_PersUserId", AuthenticatedUserLookup.class)).thenReturn(queryMock); + TypedQuery queryStub = Mockito.mock(TypedQuery.class); + AuthenticatedUserLookup lookupStub = Mockito.mock(AuthenticatedUserLookup.class); + Mockito.when(lookupStub.getAuthenticatedUser()).thenReturn(authenticatedUser); + Mockito.when(queryStub.getSingleResult()).thenReturn(lookupStub); + Mockito.when(sut.em.createNamedQuery("AuthenticatedUserLookup.findByAuthPrvID_PersUserId", AuthenticatedUserLookup.class)).thenReturn(queryStub); return authenticatedUser; } private void setupAuthenticatedUserQueryWithNoResult() { - TypedQuery queryMock = Mockito.mock(TypedQuery.class); - Mockito.when(queryMock.getSingleResult()).thenThrow(new NoResultException()); - Mockito.when(sut.em.createNamedQuery("AuthenticatedUserLookup.findByAuthPrvID_PersUserId", AuthenticatedUserLookup.class)).thenReturn(queryMock); + TypedQuery queryStub = Mockito.mock(TypedQuery.class); + Mockito.when(queryStub.getSingleResult()).thenThrow(new NoResultException()); + Mockito.when(sut.em.createNamedQuery("AuthenticatedUserLookup.findByAuthPrvID_PersUserId", AuthenticatedUserLookup.class)).thenReturn(queryStub); + } + + private void setUpOIDCProviderWhichValidatesToken() throws ParseException, IOException, OAuth2Exception { + OIDCAuthProvider oidcAuthProviderStub = stubOIDCAuthProvider("OIDC"); + + BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); + + // Stub the UserInfo returned by the provider + UserInfo userInfoStub = Mockito.mock(UserInfo.class); + Mockito.when(oidcAuthProviderStub.getUserInfo(token)).thenReturn(Optional.of(userInfoStub)); + + // Stub OAuth2UserRecord and its associated UserRecordIdentifier + OAuth2UserRecord oAuth2UserRecordStub = Mockito.mock(OAuth2UserRecord.class); + UserRecordIdentifier userRecordIdentifierStub = Mockito.mock(UserRecordIdentifier.class); + Mockito.when(userRecordIdentifierStub.getUserIdInRepo()).thenReturn("testUserId"); + Mockito.when(userRecordIdentifierStub.getUserRepoId()).thenReturn("testRepoId"); + Mockito.when(oAuth2UserRecordStub.getUserRecordIdentifier()).thenReturn(userRecordIdentifierStub); + + // Stub the OIDCAuthProvider to return OAuth2UserRecord + Mockito.when(oidcAuthProviderStub.getUserRecord(userInfoStub)).thenReturn(oAuth2UserRecordStub); + } + + private OIDCAuthProvider stubOIDCAuthProvider(String providerID) { + OIDCAuthProvider oidcAuthProviderStub = Mockito.mock(OIDCAuthProvider.class); + Mockito.when(oidcAuthProviderStub.getId()).thenReturn(providerID); + Mockito.when(sut.authProvidersRegistrationService.getAuthenticationProvidersMap()).thenReturn(Map.of(providerID, oidcAuthProviderStub)); + return oidcAuthProviderStub; } } diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java index bb6d2e609ae..a626e155336 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java @@ -1,12 +1,11 @@ package edu.harvard.iq.dataverse.engine.command.impl; -import com.nimbusds.openid.connect.sdk.claims.UserInfo; import edu.harvard.iq.dataverse.api.dto.UserDTO; import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; -import edu.harvard.iq.dataverse.authorization.OIDCUserInfo; import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; +import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2UserRecord; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.engine.command.CommandContext; import edu.harvard.iq.dataverse.engine.command.exception.CommandException; @@ -34,21 +33,35 @@ class RegisterOIDCUserCommandTest { private static final String TEST_BEARER_TOKEN = "Bearer test"; - - private UserDTO userDTO; + private static final String TEST_USERNAME = "username"; + private static final AuthenticatedUserDisplayInfo TEST_MISSING_CLAIMS_DISPLAY_INFO = new AuthenticatedUserDisplayInfo( + null, + null, + null, + "", + "" + ); + private static final AuthenticatedUserDisplayInfo TEST_VALID_DISPLAY_INFO = new AuthenticatedUserDisplayInfo( + "FirstName", + "LastName", + "user@example.com", + "", + "" + ); + + private UserDTO testUserDTO; @Mock - private CommandContext context; + private CommandContext contextStub; @Mock - private AuthenticationServiceBean authServiceMock; + private AuthenticationServiceBean authServiceStub; @InjectMocks private RegisterOIDCUserCommand sut; + private OAuth2UserRecord oAuth2UserRecordStub; private UserRecordIdentifier userRecordIdentifierMock; - private UserInfo userInfoMock; - private OIDCUserInfo OIDCUserInfoMock; private AuthenticatedUser existingTestUser; @BeforeEach @@ -57,31 +70,36 @@ void setUp() { setUpDefaultUserDTO(); userRecordIdentifierMock = mock(UserRecordIdentifier.class); - userInfoMock = mock(UserInfo.class); - OIDCUserInfoMock = new OIDCUserInfo(userRecordIdentifierMock, userInfoMock); + oAuth2UserRecordStub = mock(OAuth2UserRecord.class); existingTestUser = new AuthenticatedUser(); - when(context.authentication()).thenReturn(authServiceMock); - sut = new RegisterOIDCUserCommand(makeRequest(), TEST_BEARER_TOKEN, userDTO); + when(oAuth2UserRecordStub.getUserRecordIdentifier()).thenReturn(userRecordIdentifierMock); + when(contextStub.authentication()).thenReturn(authServiceStub); + + sut = new RegisterOIDCUserCommand(makeRequest(), TEST_BEARER_TOKEN, testUserDTO); } private void setUpDefaultUserDTO() { - userDTO = new UserDTO(); - userDTO.setTermsAccepted(true); - userDTO.setFirstName("FirstName"); - userDTO.setLastName("LastName"); - userDTO.setUsername("username"); - userDTO.setEmailAddress("user@example.com"); + testUserDTO = new UserDTO(); + testUserDTO.setTermsAccepted(true); + testUserDTO.setFirstName("FirstName"); + testUserDTO.setLastName("LastName"); + testUserDTO.setUsername("username"); + testUserDTO.setEmailAddress("user@example.com"); } @Test - public void execute_completedUserDTOWithUnacceptedTerms_provideMissingClaimsDisabled() throws AuthorizationException { - userDTO.setTermsAccepted(false); - when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(null); - when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(null); - when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); + public void execute_completedUserDTOWithUnacceptedTerms_missingClaimsInProvider_provideMissingClaimsFeatureFlagDisabled() throws AuthorizationException { + testUserDTO.setTermsAccepted(false); + + when(authServiceStub.getAuthenticatedUserByEmail(testUserDTO.getEmailAddress())).thenReturn(null); + when(authServiceStub.getAuthenticatedUser(testUserDTO.getUsername())).thenReturn(null); + when(authServiceStub.verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)).thenReturn(oAuth2UserRecordStub); + + when(oAuth2UserRecordStub.getUsername()).thenReturn(null); + when(oAuth2UserRecordStub.getDisplayInfo()).thenReturn(TEST_MISSING_CLAIMS_DISPLAY_INFO); - assertThatThrownBy(() -> sut.execute(context)) + assertThatThrownBy(() -> sut.execute(contextStub)) .isInstanceOf(InvalidFieldsCommandException.class) .satisfies(exception -> { InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; @@ -96,17 +114,21 @@ public void execute_completedUserDTOWithUnacceptedTerms_provideMissingClaimsDisa @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") - public void execute_uncompletedUserDTOWithUnacceptedTerms_provideMissingClaimsEnabled() throws AuthorizationException { - userDTO.setTermsAccepted(false); - userDTO.setEmailAddress(null); - userDTO.setUsername(null); - userDTO.setFirstName(null); - userDTO.setLastName(null); - when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(null); - when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(null); - when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); - - assertThatThrownBy(() -> sut.execute(context)) + public void execute_uncompletedUserDTOWithUnacceptedTerms_missingClaimsInProvider_provideMissingClaimsFeatureFlagEnabled() throws AuthorizationException { + testUserDTO.setTermsAccepted(false); + testUserDTO.setEmailAddress(null); + testUserDTO.setUsername(null); + testUserDTO.setFirstName(null); + testUserDTO.setLastName(null); + + when(oAuth2UserRecordStub.getUsername()).thenReturn(null); + when(oAuth2UserRecordStub.getDisplayInfo()).thenReturn(TEST_MISSING_CLAIMS_DISPLAY_INFO); + + when(authServiceStub.getAuthenticatedUserByEmail(testUserDTO.getEmailAddress())).thenReturn(null); + when(authServiceStub.getAuthenticatedUser(testUserDTO.getUsername())).thenReturn(null); + when(authServiceStub.verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)).thenReturn(oAuth2UserRecordStub); + + assertThatThrownBy(() -> sut.execute(contextStub)) .isInstanceOf(InvalidFieldsCommandException.class) .satisfies(exception -> { InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; @@ -121,12 +143,15 @@ public void execute_uncompletedUserDTOWithUnacceptedTerms_provideMissingClaimsEn @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") - public void execute_acceptedTerms_unavailableEmailAndUsername_provideMissingClaimsEnabled() throws AuthorizationException { - when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(existingTestUser); - when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(existingTestUser); - when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); + public void execute_acceptedTerms_unavailableEmailAndUsername_missingClaimsInProvider_provideMissingClaimsFeatureFlagEnabled() throws AuthorizationException { + when(authServiceStub.getAuthenticatedUserByEmail(testUserDTO.getEmailAddress())).thenReturn(existingTestUser); + when(authServiceStub.getAuthenticatedUser(testUserDTO.getUsername())).thenReturn(existingTestUser); + when(authServiceStub.verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)).thenReturn(oAuth2UserRecordStub); - assertThatThrownBy(() -> sut.execute(context)) + when(oAuth2UserRecordStub.getUsername()).thenReturn(null); + when(oAuth2UserRecordStub.getDisplayInfo()).thenReturn(TEST_MISSING_CLAIMS_DISPLAY_INFO); + + assertThatThrownBy(() -> sut.execute(contextStub)) .isInstanceOf(InvalidFieldsCommandException.class) .satisfies(exception -> { InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; @@ -140,43 +165,46 @@ public void execute_acceptedTerms_unavailableEmailAndUsername_provideMissingClai @Test void execute_throwsPermissionException_onAuthorizationException() throws AuthorizationException { String testAuthorizationExceptionMessage = "Authorization failed"; - when(context.authentication().verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)) + when(contextStub.authentication().verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)) .thenThrow(new AuthorizationException(testAuthorizationExceptionMessage)); - assertThatThrownBy(() -> sut.execute(context)) + assertThatThrownBy(() -> sut.execute(contextStub)) .isInstanceOf(PermissionException.class) .hasMessageContaining(testAuthorizationExceptionMessage); - verify(context.authentication(), times(1)).verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN); + verify(contextStub.authentication(), times(1)).verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN); } @Test void execute_throwsIllegalCommandException_ifUserAlreadyRegisteredWithToken() throws AuthorizationException { - when(context.authentication().verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)) - .thenReturn(OIDCUserInfoMock); - when(context.authentication().lookupUser(userRecordIdentifierMock)).thenReturn(new AuthenticatedUser()); + when(contextStub.authentication().verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)) + .thenReturn(oAuth2UserRecordStub); + when(contextStub.authentication().lookupUser(userRecordIdentifierMock)).thenReturn(new AuthenticatedUser()); - assertThatThrownBy(() -> sut.execute(context)) + assertThatThrownBy(() -> sut.execute(contextStub)) .isInstanceOf(IllegalCommandException.class) .hasMessageContaining(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userAlreadyRegisteredWithToken")); - verify(context.authentication(), times(1)).lookupUser(userRecordIdentifierMock); + verify(contextStub.authentication(), times(1)).lookupUser(userRecordIdentifierMock); } @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") - void execute_happyPath_withoutAffiliationAndPosition_provideMissingClaimsEnabled() throws AuthorizationException, CommandException { - when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); + void execute_happyPath_withoutAffiliationAndPosition_missingClaimsInProvider_provideMissingClaimsFeatureFlagEnabled() throws AuthorizationException, CommandException { + when(authServiceStub.verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)).thenReturn(oAuth2UserRecordStub); + + when(oAuth2UserRecordStub.getUsername()).thenReturn(null); + when(oAuth2UserRecordStub.getDisplayInfo()).thenReturn(TEST_MISSING_CLAIMS_DISPLAY_INFO); - sut.execute(context); + sut.execute(contextStub); - verify(authServiceMock, times(1)).createAuthenticatedUser( + verify(authServiceStub, times(1)).createAuthenticatedUser( eq(userRecordIdentifierMock), - eq(userDTO.getUsername()), + eq(testUserDTO.getUsername()), eq(new AuthenticatedUserDisplayInfo( - userDTO.getFirstName(), - userDTO.getLastName(), - userDTO.getEmailAddress(), + testUserDTO.getFirstName(), + testUserDTO.getLastName(), + testUserDTO.getEmailAddress(), "", "") ), @@ -186,23 +214,26 @@ void execute_happyPath_withoutAffiliationAndPosition_provideMissingClaimsEnabled @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") - void execute_happyPath_withAffiliationAndPosition_provideMissingClaimsEnabled() throws AuthorizationException, CommandException { - userDTO.setPosition("test position"); - userDTO.setAffiliation("test affiliation"); + void execute_happyPath_withAffiliationAndPosition_missingClaimsInProvider_provideMissingClaimsFeatureFlagEnabled() throws AuthorizationException, CommandException { + testUserDTO.setPosition("test position"); + testUserDTO.setAffiliation("test affiliation"); + + when(authServiceStub.verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)).thenReturn(oAuth2UserRecordStub); - when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); + when(oAuth2UserRecordStub.getUsername()).thenReturn(null); + when(oAuth2UserRecordStub.getDisplayInfo()).thenReturn(TEST_MISSING_CLAIMS_DISPLAY_INFO); - sut.execute(context); + sut.execute(contextStub); - verify(authServiceMock, times(1)).createAuthenticatedUser( + verify(authServiceStub, times(1)).createAuthenticatedUser( eq(userRecordIdentifierMock), - eq(userDTO.getUsername()), + eq(testUserDTO.getUsername()), eq(new AuthenticatedUserDisplayInfo( - userDTO.getFirstName(), - userDTO.getLastName(), - userDTO.getEmailAddress(), - userDTO.getAffiliation(), - userDTO.getPosition()) + testUserDTO.getFirstName(), + testUserDTO.getLastName(), + testUserDTO.getEmailAddress(), + testUserDTO.getAffiliation(), + testUserDTO.getPosition()) ), eq(true) ); @@ -210,28 +241,57 @@ void execute_happyPath_withAffiliationAndPosition_provideMissingClaimsEnabled() @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") - void execute_conflictingClaims_provideMissingClaimsEnabled() throws AuthorizationException { - when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); + void execute_conflictingClaimsInProvider_provideMissingClaimsFeatureFlagEnabled() throws AuthorizationException { + when(authServiceStub.verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)).thenReturn(oAuth2UserRecordStub); - when(userInfoMock.getPreferredUsername()).thenReturn("conflictingUsername"); - when(userInfoMock.getGivenName()).thenReturn("conflictingFirstName"); - when(userInfoMock.getFamilyName()).thenReturn("conflictingLastName"); - when(userInfoMock.getEmailAddress()).thenReturn("conflicting@example.com"); + when(oAuth2UserRecordStub.getUsername()).thenReturn(TEST_USERNAME); + when(oAuth2UserRecordStub.getDisplayInfo()).thenReturn(TEST_VALID_DISPLAY_INFO); - userDTO.setUsername("username"); - userDTO.setFirstName("FirstName"); - userDTO.setLastName("LastName"); - userDTO.setEmailAddress("user@example.com"); + testUserDTO.setUsername("conflictingUsername"); + testUserDTO.setFirstName("conflictingFirstName"); + testUserDTO.setLastName("conflictingLastName"); + testUserDTO.setEmailAddress("conflictingemail@example.com"); - assertThatThrownBy(() -> sut.execute(context)) + assertThatThrownBy(() -> sut.execute(contextStub)) .isInstanceOf(InvalidFieldsCommandException.class) .satisfies(exception -> { InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; assertThat(ex.getFieldErrors()) .containsEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("username"))) + .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("emailAddress"))) .containsEntry("firstName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("firstName"))) .containsEntry("lastName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("lastName"))) .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("emailAddress"))); }); } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") + void execute_happyPath_withoutAffiliationAndPosition_claimsInProvider_provideMissingClaimsFeatureFlagEnabled() throws AuthorizationException, CommandException { + testUserDTO.setTermsAccepted(true); + testUserDTO.setEmailAddress(null); + testUserDTO.setUsername(null); + testUserDTO.setFirstName(null); + testUserDTO.setLastName(null); + + when(authServiceStub.verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)).thenReturn(oAuth2UserRecordStub); + + when(oAuth2UserRecordStub.getUsername()).thenReturn(TEST_USERNAME); + when(oAuth2UserRecordStub.getDisplayInfo()).thenReturn(TEST_VALID_DISPLAY_INFO); + + sut.execute(contextStub); + + verify(authServiceStub, times(1)).createAuthenticatedUser( + eq(userRecordIdentifierMock), + eq(TEST_USERNAME), + eq(new AuthenticatedUserDisplayInfo( + TEST_VALID_DISPLAY_INFO.getFirstName(), + TEST_VALID_DISPLAY_INFO.getLastName(), + TEST_VALID_DISPLAY_INFO.getEmailAddress(), + "", + "") + ), + eq(true) + ); + } } From 07794f39da45f1ea6b72a013f7a848ed1e1e3e5a Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 20 Nov 2024 14:47:13 +0000 Subject: [PATCH 51/62] Removed: unused OIDCUserInfo --- .../AuthenticationServiceBean.java | 2 +- .../dataverse/authorization/OIDCUserInfo.java | 33 ------------------- 2 files changed, 1 insertion(+), 34 deletions(-) delete mode 100644 src/main/java/edu/harvard/iq/dataverse/authorization/OIDCUserInfo.java diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java index f5c354defeb..032c1dd5164 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -996,7 +996,7 @@ public AuthenticatedUser lookupUserByOIDCBearerToken(String bearerToken) throws * Verifies the given OIDC bearer token and retrieves the corresponding OAuth2UserRecord. * * @param bearerToken The OIDC bearer token. - * @return An {@link OIDCUserInfo} containing the user's identifier and user info. + * @return An {@link OAuth2UserRecord} containing the user's info. * @throws AuthorizationException If the token is invalid or if no OIDC providers are available. */ public OAuth2UserRecord verifyOIDCBearerTokenAndGetOAuth2UserRecord(String bearerToken) throws AuthorizationException { diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/OIDCUserInfo.java b/src/main/java/edu/harvard/iq/dataverse/authorization/OIDCUserInfo.java deleted file mode 100644 index 8c4cf165f18..00000000000 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/OIDCUserInfo.java +++ /dev/null @@ -1,33 +0,0 @@ -package edu.harvard.iq.dataverse.authorization; - -import com.nimbusds.openid.connect.sdk.claims.UserInfo; - -/** - * Encapsulates both the user's identifier ({@link UserRecordIdentifier}) and the user's claims information - * ({@link UserInfo}) retrieved from an OIDC (OpenID Connect) bearer token. - *

- * This class serves as a container for both the {@link UserRecordIdentifier}, which uniquely identifies - * the user within the system, and the {@link UserInfo}, which holds the user's claims data provided by - * an OIDC provider. It simplifies the management of these related pieces of user data when handling - * OIDC token validation and authorization processes. - * - * @see UserRecordIdentifier - * @see UserInfo - */ -public class OIDCUserInfo { - private final UserRecordIdentifier userRecordIdentifier; - private final UserInfo userClaimsInfo; - - public OIDCUserInfo(UserRecordIdentifier userRecordIdentifier, UserInfo userClaimsInfo) { - this.userRecordIdentifier = userRecordIdentifier; - this.userClaimsInfo = userClaimsInfo; - } - - public UserRecordIdentifier getUserRecordIdentifier() { - return userRecordIdentifier; - } - - public UserInfo getUserClaimsInfo() { - return userClaimsInfo; - } -} From 7d88c8efb3db5ba22200d77a9a7620b4e5082967 Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 20 Nov 2024 14:53:39 +0000 Subject: [PATCH 52/62] Added: release notes for #10959 --- doc/release-notes/10959-oidc-api-auth-ext.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 doc/release-notes/10959-oidc-api-auth-ext.md diff --git a/doc/release-notes/10959-oidc-api-auth-ext.md b/doc/release-notes/10959-oidc-api-auth-ext.md new file mode 100644 index 00000000000..37c5003e960 --- /dev/null +++ b/doc/release-notes/10959-oidc-api-auth-ext.md @@ -0,0 +1,9 @@ +Extends the OIDC API auth mechanism (available through feature flag ``api-bearer-auth``) to properly handle cases +where ``BearerTokenAuthMechanism`` successfully validates the token but cannot identify any Dataverse user because there +is no account associated with the token. + +To register a new user who has authenticated via an OIDC provider, a new endpoint has been +implemented (``/users/register``). A feature flag called ``api-bearer-auth-json-claims`` has been implemented to allow +sending missing user claims in the request JSON. This is useful when the identity provider does not supply the necessary +claims. However, this flag will only be considered if the ``api-bearer-auth`` feature flag is enabled. If the latter is +not enabled, the ``api-bearer-auth-json-claims`` flag will be ignored. From ae58595361b24bb8dbb3372e25561e3999a7296b Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 20 Nov 2024 15:40:54 +0000 Subject: [PATCH 53/62] Removed: duplicated release notes doc --- doc/release-notes/10959-bearer-token-user-registration.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 doc/release-notes/10959-bearer-token-user-registration.md diff --git a/doc/release-notes/10959-bearer-token-user-registration.md b/doc/release-notes/10959-bearer-token-user-registration.md deleted file mode 100644 index 329db550cc9..00000000000 --- a/doc/release-notes/10959-bearer-token-user-registration.md +++ /dev/null @@ -1,5 +0,0 @@ -The OIDC Bearer token API authentication feature (available through a feature flag) has been extended to allow the registration of new users in Dataverse when there is no user account associated with the bearer token. - -Specifically, a new endpoint (users/register) has been implemented, to which the bearer token and new user account information are sent, allowing the identity provider user to be linked to a Dataverse account. - -In this way, the user will be recognized in future requests using the bearer token in the BearerTokenAuthMechanism. From f360b91096fae07fabece89059598d42ffd2a58f Mon Sep 17 00:00:00 2001 From: GPortas Date: Thu, 21 Nov 2024 09:54:48 +0000 Subject: [PATCH 54/62] Changed: checking when claim is blank in the provider in RegisterOIDCUserCommand --- .../command/impl/RegisterOIDCUserCommand.java | 4 +-- .../impl/RegisterOIDCUserCommandTest.java | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java index 2c94a08b088..e3d861c2dbf 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java @@ -89,7 +89,7 @@ private Map validateConflictingClaims(OAuth2UserRecord oAuth2Use } private void addFieldErrorIfConflict(String fieldName, String claimValue, String existingValue, Map fieldErrors) { - if (claimValue != null && existingValue != null && !claimValue.equals(existingValue)) { + if (claimValue != null && !claimValue.trim().isEmpty() && existingValue != null && !claimValue.equals(existingValue)) { String errorMessage = BundleUtil.getStringFromBundle( "registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of(fieldName) @@ -123,7 +123,7 @@ private void throwInvalidFieldsCommandExceptionIfErrorsExist(Map } private String getValueOrDefault(String oidcValue, String dtoValue) { - return (oidcValue == null || oidcValue.isEmpty()) ? dtoValue : oidcValue; + return (oidcValue == null || oidcValue.trim().isEmpty()) ? dtoValue : oidcValue; } private void validateUserFields(CommandContext ctxt, boolean provideMissingClaimsEnabled) throws InvalidFieldsCommandException { diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java index a626e155336..990b11066e2 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java @@ -294,4 +294,35 @@ void execute_happyPath_withoutAffiliationAndPosition_claimsInProvider_provideMis eq(true) ); } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") + void execute_happyPath_withoutAffiliationAndPosition_blankClaimInProviderProvidedInJson_provideMissingClaimsFeatureFlagEnabled() throws AuthorizationException, CommandException { + String testUsername = "usernameNotBlank"; + testUserDTO.setUsername(testUsername); + testUserDTO.setTermsAccepted(true); + testUserDTO.setEmailAddress(null); + testUserDTO.setFirstName(null); + testUserDTO.setLastName(null); + + when(authServiceStub.verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)).thenReturn(oAuth2UserRecordStub); + + when(oAuth2UserRecordStub.getUsername()).thenReturn(" "); + when(oAuth2UserRecordStub.getDisplayInfo()).thenReturn(TEST_VALID_DISPLAY_INFO); + + sut.execute(contextStub); + + verify(authServiceStub, times(1)).createAuthenticatedUser( + eq(userRecordIdentifierMock), + eq(testUsername), + eq(new AuthenticatedUserDisplayInfo( + TEST_VALID_DISPLAY_INFO.getFirstName(), + TEST_VALID_DISPLAY_INFO.getLastName(), + TEST_VALID_DISPLAY_INFO.getEmailAddress(), + "", + "") + ), + eq(true) + ); + } } From 16f8e04c2c2813df88b4f08a8caa74adac15bb26 Mon Sep 17 00:00:00 2001 From: GPortas Date: Thu, 21 Nov 2024 10:23:45 +0000 Subject: [PATCH 55/62] Added: validate user DTO has no claims when feature flag is disabled --- .../command/impl/RegisterOIDCUserCommand.java | 35 +++++++++++++++++++ src/main/java/propertyFiles/Bundle.properties | 1 + .../impl/RegisterOIDCUserCommandTest.java | 22 ++++++++++++ 3 files changed, 58 insertions(+) diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java index e3d861c2dbf..ad0bf4470d3 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java @@ -73,6 +73,8 @@ private void updateUserDTO(OAuth2UserRecord oAuth2UserRecord, boolean provideMis throwInvalidFieldsCommandExceptionIfErrorsExist(fieldErrors); updateUserDTOWithClaims(oAuth2UserRecord); } else { + Map fieldErrors = validateUserDTOHasNoClaims(); + throwInvalidFieldsCommandExceptionIfErrorsExist(fieldErrors); overwriteUserDTOWithClaims(oAuth2UserRecord); } } @@ -98,6 +100,39 @@ private void addFieldErrorIfConflict(String fieldName, String claimValue, String } } + private Map validateUserDTOHasNoClaims() { + Map fieldErrors = new HashMap<>(); + if (userDTO.getUsername() != null) { + String errorMessage = BundleUtil.getStringFromBundle( + "registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON", + List.of(FIELD_USERNAME) + ); + fieldErrors.put(FIELD_USERNAME, errorMessage); + } + if (userDTO.getEmailAddress() != null) { + String errorMessage = BundleUtil.getStringFromBundle( + "registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON", + List.of(FIELD_EMAIL_ADDRESS) + ); + fieldErrors.put(FIELD_EMAIL_ADDRESS, errorMessage); + } + if (userDTO.getFirstName() != null) { + String errorMessage = BundleUtil.getStringFromBundle( + "registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON", + List.of(FIELD_FIRST_NAME) + ); + fieldErrors.put(FIELD_FIRST_NAME, errorMessage); + } + if (userDTO.getLastName() != null) { + String errorMessage = BundleUtil.getStringFromBundle( + "registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON", + List.of(FIELD_LAST_NAME) + ); + fieldErrors.put(FIELD_LAST_NAME, errorMessage); + } + return fieldErrors; + } + private void updateUserDTOWithClaims(OAuth2UserRecord oAuth2UserRecord) { userDTO.setUsername(getValueOrDefault(oAuth2UserRecord.getUsername(), userDTO.getUsername())); userDTO.setFirstName(getValueOrDefault(oAuth2UserRecord.getDisplayInfo().getFirstName(), userDTO.getFirstName())); diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index 62b1c3ed3cd..16dd8e69f4a 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3074,6 +3074,7 @@ registerOidcUserCommand.errors.invalidFields=The provided fields are invalid for registerOidcUserCommand.errors.userShouldAcceptTerms=Terms should be accepted. registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider=Unable to set {0} because it conflicts with an existing claim from the OIDC identity provider. registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired=It is required to include the field {0} in the request JSON for registering the user. +registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON=Unable to set field {0} via JSON because the api-bearer-auth-json-claims feature flag is disabled. registerOidcUserCommand.errors.provideMissingClaimsDisabled.fieldRequired=The OIDC identity provider does not provide the user claim {0}, which is required for user registration. Please contact an administrator. registerOidcUserCommand.errors.emailAddressInUse=Email already in use. registerOidcUserCommand.errors.usernameInUse=Username already in use. diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java index 990b11066e2..c6b6e77d23e 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java @@ -91,6 +91,10 @@ private void setUpDefaultUserDTO() { @Test public void execute_completedUserDTOWithUnacceptedTerms_missingClaimsInProvider_provideMissingClaimsFeatureFlagDisabled() throws AuthorizationException { testUserDTO.setTermsAccepted(false); + testUserDTO.setEmailAddress(null); + testUserDTO.setUsername(null); + testUserDTO.setFirstName(null); + testUserDTO.setLastName(null); when(authServiceStub.getAuthenticatedUserByEmail(testUserDTO.getEmailAddress())).thenReturn(null); when(authServiceStub.getAuthenticatedUser(testUserDTO.getUsername())).thenReturn(null); @@ -188,6 +192,24 @@ void execute_throwsIllegalCommandException_ifUserAlreadyRegisteredWithToken() th verify(contextStub.authentication(), times(1)).lookupUser(userRecordIdentifierMock); } + @Test + void execute_throwsInvalidFieldsCommandException_ifUserDTOHasClaimsAndProvideMissingClaimsFeatureFlagIsDisabled() throws AuthorizationException { + when(contextStub.authentication().verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)) + .thenReturn(oAuth2UserRecordStub); + + assertThatThrownBy(() -> sut.execute(contextStub)) + .isInstanceOf(InvalidFieldsCommandException.class) + .satisfies(exception -> { + InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; + assertThat(ex.getFieldErrors()) + .containsEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON", List.of("username"))) + .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON", List.of("emailAddress"))) + .containsEntry("firstName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON", List.of("firstName"))) + .containsEntry("lastName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON", List.of("lastName"))) + .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON", List.of("emailAddress"))); + }); + } + @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") void execute_happyPath_withoutAffiliationAndPosition_missingClaimsInProvider_provideMissingClaimsFeatureFlagEnabled() throws AuthorizationException, CommandException { From cc99a8b338c09b40ba938bbd03239a311c1ca54b Mon Sep 17 00:00:00 2001 From: GPortas Date: Thu, 21 Nov 2024 10:31:28 +0000 Subject: [PATCH 56/62] Added: test case to RegisterOIDCUserCommandTest for blank claim values --- .../command/impl/RegisterOIDCUserCommandTest.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java index c6b6e77d23e..934d4296f09 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java @@ -18,6 +18,8 @@ import edu.harvard.iq.dataverse.util.testing.LocalJvmSettings; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -317,11 +319,12 @@ void execute_happyPath_withoutAffiliationAndPosition_claimsInProvider_provideMis ); } - @Test + @ParameterizedTest + @ValueSource(strings = {" ", ""}) @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") - void execute_happyPath_withoutAffiliationAndPosition_blankClaimInProviderProvidedInJson_provideMissingClaimsFeatureFlagEnabled() throws AuthorizationException, CommandException { - String testUsername = "usernameNotBlank"; - testUserDTO.setUsername(testUsername); + void execute_happyPath_withoutAffiliationAndPosition_blankClaimInProviderProvidedInJson_provideMissingClaimsFeatureFlagEnabled(String testBlankUsername) throws AuthorizationException, CommandException { + String testUsernameNotBlank = "usernameNotBlank"; + testUserDTO.setUsername(testUsernameNotBlank); testUserDTO.setTermsAccepted(true); testUserDTO.setEmailAddress(null); testUserDTO.setFirstName(null); @@ -329,14 +332,14 @@ void execute_happyPath_withoutAffiliationAndPosition_blankClaimInProviderProvide when(authServiceStub.verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)).thenReturn(oAuth2UserRecordStub); - when(oAuth2UserRecordStub.getUsername()).thenReturn(" "); + when(oAuth2UserRecordStub.getUsername()).thenReturn(testBlankUsername); when(oAuth2UserRecordStub.getDisplayInfo()).thenReturn(TEST_VALID_DISPLAY_INFO); sut.execute(contextStub); verify(authServiceStub, times(1)).createAuthenticatedUser( eq(userRecordIdentifierMock), - eq(testUsername), + eq(testUsernameNotBlank), eq(new AuthenticatedUserDisplayInfo( TEST_VALID_DISPLAY_INFO.getFirstName(), TEST_VALID_DISPLAY_INFO.getLastName(), From 353da1d6991a87d99926bb8c6fe8d020579131d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Tue, 3 Dec 2024 13:17:54 -0300 Subject: [PATCH 57/62] chore: update docs --- doc/release-notes/10959-oidc-api-auth-ext.md | 4 ++-- doc/sphinx-guides/source/api/auth.rst | 2 +- src/main/java/propertyFiles/Bundle.properties | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/release-notes/10959-oidc-api-auth-ext.md b/doc/release-notes/10959-oidc-api-auth-ext.md index 37c5003e960..e135fcccfd1 100644 --- a/doc/release-notes/10959-oidc-api-auth-ext.md +++ b/doc/release-notes/10959-oidc-api-auth-ext.md @@ -3,7 +3,7 @@ where ``BearerTokenAuthMechanism`` successfully validates the token but cannot i is no account associated with the token. To register a new user who has authenticated via an OIDC provider, a new endpoint has been -implemented (``/users/register``). A feature flag called ``api-bearer-auth-json-claims`` has been implemented to allow +implemented (``/users/register``). A feature flag called ``api-bearer-auth-provide-missing-claims`` has been implemented to allow sending missing user claims in the request JSON. This is useful when the identity provider does not supply the necessary claims. However, this flag will only be considered if the ``api-bearer-auth`` feature flag is enabled. If the latter is -not enabled, the ``api-bearer-auth-json-claims`` flag will be ignored. +not enabled, the ``api-bearer-auth-provide-missing-claims`` flag will be ignored. diff --git a/doc/sphinx-guides/source/api/auth.rst b/doc/sphinx-guides/source/api/auth.rst index 51234ad08bc..2784703ddae 100644 --- a/doc/sphinx-guides/source/api/auth.rst +++ b/doc/sphinx-guides/source/api/auth.rst @@ -91,7 +91,7 @@ It is essential to send a JSON that includes the property ``termsAccepted`` set In this JSON, we can also include the fields ``position`` or ``affiliation``, in the same way as when we register a user through the Dataverse UI. These fields are optional, and if not provided, they will be persisted as empty in Dataverse. -Beyond the ``api-bearer-auth`` feature flag, there is another flag called ``api-bearer-auth-json-claims`` that can be enabled to allow sending missing user claims in the registration JSON. This is useful when the identity provider does not supply the necessary claims. However, this flag will only be considered if the ``api-bearer-auth`` feature flag is enabled. If the latter is not enabled, the ``api-bearer-auth-json-claims`` flag will be ignored. +Beyond the ``api-bearer-auth`` feature flag, there is another flag called ``api-bearer-auth-provide-missing-claims`` that can be enabled to allow sending missing user claims in the registration JSON. This is useful when the identity provider does not supply the necessary claims. However, this flag will only be considered if the ``api-bearer-auth`` feature flag is enabled. If the latter is not enabled, the ``api-bearer-auth-provide-missing-claims`` flag will be ignored. With the ``api-bearer-auth`` feature flag enabled, you can include the following properties in the request JSON: diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index 16dd8e69f4a..13364904ab0 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3074,7 +3074,7 @@ registerOidcUserCommand.errors.invalidFields=The provided fields are invalid for registerOidcUserCommand.errors.userShouldAcceptTerms=Terms should be accepted. registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider=Unable to set {0} because it conflicts with an existing claim from the OIDC identity provider. registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired=It is required to include the field {0} in the request JSON for registering the user. -registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON=Unable to set field {0} via JSON because the api-bearer-auth-json-claims feature flag is disabled. +registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON=Unable to set field {0} via JSON because the api-bearer-auth-provide-missing-claims feature flag is disabled. registerOidcUserCommand.errors.provideMissingClaimsDisabled.fieldRequired=The OIDC identity provider does not provide the user claim {0}, which is required for user registration. Please contact an administrator. registerOidcUserCommand.errors.emailAddressInUse=Email already in use. registerOidcUserCommand.errors.usernameInUse=Username already in use. From 600fe58d6456593a85926d733ec0d64449c005de Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 11 Dec 2024 15:07:31 +0000 Subject: [PATCH 58/62] Added: feature flag API_BEARER_AUTH_HANDLE_TOS_ACCEPTANCE_IN_IDP to FeatureFlags.java --- .../harvard/iq/dataverse/settings/FeatureFlags.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java index b3774c3fe06..2242b0f51c6 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java @@ -48,6 +48,17 @@ public enum FeatureFlags { * @since Dataverse @TODO: */ API_BEARER_AUTH_PROVIDE_MISSING_CLAIMS("api-bearer-auth-provide-missing-claims"), + /** + * Specifies that Terms of Service acceptance is handled by the IdP, eliminating the need to include + * ToS acceptance boolean parameter (termsAccepted) in the OIDC user registration request body. + * + *

The value of this feature flag is only considered when the feature flag + * {@link #API_BEARER_AUTH} is enabled.

+ * + * @apiNote Raise flag by setting "dataverse.feature.api-bearer-auth-handle-tos-acceptance-in-idp" + * @since Dataverse @TODO: + */ + API_BEARER_AUTH_HANDLE_TOS_ACCEPTANCE_IN_IDP("api-bearer-auth-handle-tos-acceptance-in-idp"), /** * For published (public) objects, don't use a join when searching Solr. * Experimental! Requires a reindex with the following feature flag enabled, From fac0dc373317c892702b84c4d86e1add454f54e6 Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 11 Dec 2024 15:25:20 +0000 Subject: [PATCH 59/62] Added: managing API_BEARER_AUTH_HANDLE_TOS_ACCEPTANCE_IN_IDP feature flag in RegisterOIDCUserCommand --- .../command/impl/RegisterOIDCUserCommand.java | 5 ++++- .../iq/dataverse/util/json/JsonParser.java | 8 +++++++- .../impl/RegisterOIDCUserCommandTest.java | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java index ad0bf4470d3..c7745c75aa9 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java @@ -164,7 +164,10 @@ private String getValueOrDefault(String oidcValue, String dtoValue) { private void validateUserFields(CommandContext ctxt, boolean provideMissingClaimsEnabled) throws InvalidFieldsCommandException { Map fieldErrors = new HashMap<>(); - validateTermsAccepted(fieldErrors); + if (!FeatureFlags.API_BEARER_AUTH_HANDLE_TOS_ACCEPTANCE_IN_IDP.enabled()) { + validateTermsAccepted(fieldErrors); + } + validateField(fieldErrors, FIELD_EMAIL_ADDRESS, userDTO.getEmailAddress(), ctxt, provideMissingClaimsEnabled); validateField(fieldErrors, FIELD_USERNAME, userDTO.getUsername(), ctxt, provideMissingClaimsEnabled); validateField(fieldErrors, FIELD_FIRST_NAME, userDTO.getFirstName(), ctxt, provideMissingClaimsEnabled); diff --git a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java index af69807247d..ce6a5920a39 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java @@ -32,6 +32,7 @@ import edu.harvard.iq.dataverse.harvest.client.HarvestingClient; import edu.harvard.iq.dataverse.license.License; import edu.harvard.iq.dataverse.license.LicenseServiceBean; +import edu.harvard.iq.dataverse.settings.FeatureFlags; import edu.harvard.iq.dataverse.settings.SettingsServiceBean; import edu.harvard.iq.dataverse.util.BundleUtil; import edu.harvard.iq.dataverse.workflow.Workflow; @@ -1107,13 +1108,18 @@ private void validate(String objectName, JsonObject jobject, String fieldName, V public UserDTO parseUserDTO(JsonObject jobj) throws JsonParseException { UserDTO userDTO = new UserDTO(); + userDTO.setUsername(jobj.getString("username", null)); userDTO.setEmailAddress(jobj.getString("emailAddress", null)); userDTO.setFirstName(jobj.getString("firstName", null)); userDTO.setLastName(jobj.getString("lastName", null)); - userDTO.setTermsAccepted(getMandatoryBoolean(jobj, "termsAccepted")); userDTO.setAffiliation(jobj.getString("affiliation", null)); userDTO.setPosition(jobj.getString("position", null)); + + if (!FeatureFlags.API_BEARER_AUTH_HANDLE_TOS_ACCEPTANCE_IN_IDP.enabled()) { + userDTO.setTermsAccepted(getMandatoryBoolean(jobj, "termsAccepted")); + } + return userDTO; } } diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java index 934d4296f09..3f6b3b0f393 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java @@ -29,6 +29,7 @@ import static edu.harvard.iq.dataverse.mocks.MocksFactory.makeRequest; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.mockito.Mockito.*; @LocalJvmSettings @@ -350,4 +351,21 @@ void execute_happyPath_withoutAffiliationAndPosition_blankClaimInProviderProvide eq(true) ); } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-handle-tos-acceptance-in-idp") + void execute_doNotThrowUnacceptedTermsError_unacceptedTermsInUserDTOAndAllClaimsInProvider_handleTosAcceptanceInIdpFeatureFlagEnabled() throws AuthorizationException { + testUserDTO.setTermsAccepted(false); + testUserDTO.setEmailAddress(null); + testUserDTO.setUsername(null); + testUserDTO.setFirstName(null); + testUserDTO.setLastName(null); + + when(authServiceStub.verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)).thenReturn(oAuth2UserRecordStub); + + when(oAuth2UserRecordStub.getUsername()).thenReturn(TEST_USERNAME); + when(oAuth2UserRecordStub.getDisplayInfo()).thenReturn(TEST_VALID_DISPLAY_INFO); + + assertDoesNotThrow(() -> sut.execute(contextStub)); + } } From 628746c9c621e4342adc3655746902ed723d2a67 Mon Sep 17 00:00:00 2001 From: GPortas Date: Thu, 12 Dec 2024 12:20:50 +0000 Subject: [PATCH 60/62] Changed: updated auth.rst docs with api-bearer-auth-handle-tos-acceptance-in-idp feature flag usage --- doc/sphinx-guides/source/api/auth.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/sphinx-guides/source/api/auth.rst b/doc/sphinx-guides/source/api/auth.rst index 2784703ddae..210c1bcd184 100644 --- a/doc/sphinx-guides/source/api/auth.rst +++ b/doc/sphinx-guides/source/api/auth.rst @@ -87,13 +87,13 @@ To register a new user who has authenticated via an OIDC provider, the following curl -H "Authorization: Bearer $TOKEN" -X POST http://localhost:8080/api/users/register --data '{"termsAccepted":true}' -It is essential to send a JSON that includes the property ``termsAccepted`` set to true, which indicates that you accept the Terms of Use of the installation. Otherwise, you will not be able to create an account. +If the feature flag ``api-bearer-auth-handle-tos-acceptance-in-idp``` is disabled, it is essential to send a JSON that includes the property ``termsAccepted``` set to true, indicating that you accept the Terms of Use of the installation. Otherwise, you will not be able to create an account. However, if the feature flag is enabled, Terms of Service acceptance is handled by the identity provider, and it is no longer necessary to include the ``termsAccepted``` parameter in the JSON. In this JSON, we can also include the fields ``position`` or ``affiliation``, in the same way as when we register a user through the Dataverse UI. These fields are optional, and if not provided, they will be persisted as empty in Dataverse. -Beyond the ``api-bearer-auth`` feature flag, there is another flag called ``api-bearer-auth-provide-missing-claims`` that can be enabled to allow sending missing user claims in the registration JSON. This is useful when the identity provider does not supply the necessary claims. However, this flag will only be considered if the ``api-bearer-auth`` feature flag is enabled. If the latter is not enabled, the ``api-bearer-auth-provide-missing-claims`` flag will be ignored. +There is another flag called ``api-bearer-auth-provide-missing-claims`` that can be enabled to allow sending missing user claims in the registration JSON. This is useful when the identity provider does not supply the necessary claims. However, this flag will only be considered if the ``api-bearer-auth`` feature flag is enabled. If the latter is not enabled, the ``api-bearer-auth-provide-missing-claims`` flag will be ignored. -With the ``api-bearer-auth`` feature flag enabled, you can include the following properties in the request JSON: +With the ``api-bearer-auth-provide-missing-claims`` feature flag enabled, you can include the following properties in the request JSON: - ``username`` - ``firstName`` From 4227eff7578f7ccb170bc076dcbcc658a2ba4ef6 Mon Sep 17 00:00:00 2001 From: GPortas Date: Thu, 12 Dec 2024 12:21:14 +0000 Subject: [PATCH 61/62] Changed: updated config.rst docs with api-bearer-auth-handle-tos-acceptance-in-idp feature flag usage --- doc/sphinx-guides/source/installation/config.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index 2d1b942b41b..6fd40b8015b 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -3349,6 +3349,9 @@ please find all known feature flags below. Any of these flags can be activated u * - api-bearer-auth-provide-missing-claims - Enables sending missing user claims in the request JSON provided during OIDC user registration, when these claims are not returned by the identity provider and are required for registration. This feature only works when the feature flag ``api-bearer-auth`` is also enabled. **Caution: Enabling this feature flag exposes the installation to potential user impersonation issues.** - ``Off`` + * - api-bearer-auth-handle-tos-acceptance-in-idp + - Specifies that Terms of Service acceptance is handled by the IdP, eliminating the need to include ToS acceptance boolean parameter (termsAccepted) in the OIDC user registration request body. This feature only works when the feature flag ``api-bearer-auth`` is also enabled. + - ``Off`` * - avoid-expensive-solr-join - Changes the way Solr queries are constructed for public content (published Collections, Datasets and Files). It removes a very expensive Solr join on all such documents, improving overall performance, especially for large instances under heavy load. Before this feature flag is enabled, the corresponding indexing feature (see next feature flag) must be turned on and a full reindex performed (otherwise public objects are not going to be shown in search results). See :doc:`/admin/solr-search-index`. - ``Off`` From abf6994f7d8e67cdfea6cb859d2385d4bed17471 Mon Sep 17 00:00:00 2001 From: GPortas Date: Thu, 12 Dec 2024 12:24:26 +0000 Subject: [PATCH 62/62] Changed: updated release notes --- doc/release-notes/10959-oidc-api-auth-ext.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/doc/release-notes/10959-oidc-api-auth-ext.md b/doc/release-notes/10959-oidc-api-auth-ext.md index e135fcccfd1..04ee2099f68 100644 --- a/doc/release-notes/10959-oidc-api-auth-ext.md +++ b/doc/release-notes/10959-oidc-api-auth-ext.md @@ -3,7 +3,12 @@ where ``BearerTokenAuthMechanism`` successfully validates the token but cannot i is no account associated with the token. To register a new user who has authenticated via an OIDC provider, a new endpoint has been -implemented (``/users/register``). A feature flag called ``api-bearer-auth-provide-missing-claims`` has been implemented to allow +implemented (``/users/register``). A feature flag named ``api-bearer-auth-provide-missing-claims`` has been implemented +to allow sending missing user claims in the request JSON. This is useful when the identity provider does not supply the necessary claims. However, this flag will only be considered if the ``api-bearer-auth`` feature flag is enabled. If the latter is not enabled, the ``api-bearer-auth-provide-missing-claims`` flag will be ignored. + +A feature flag named ``api-bearer-auth-handle-tos-acceptance-in-idp`` has been implemented. When enabled, it specifies +that Terms of Service acceptance is managed by the identity provider, eliminating the need to explicitly include the +acceptance in the user registration request JSON.