Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle unregistered users in BearerTokenAuthMechanism and implement user registration mechanism #10972

Open
wants to merge 65 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 50 commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
5b9e67e
Changed: throwing an error in BearerTokenAuthMechanismTest when token…
GPortas Oct 28, 2024
d61d5bf
Merge branch 'develop' of github.com:IQSS/dataverse into 10959-bearer…
GPortas Oct 28, 2024
e42eb5b
Changed: update BearerTokenAuthMechanismTest
GPortas Oct 28, 2024
89b3119
Changed: using separate classes for wrapped auth error responses
GPortas Oct 28, 2024
300e041
Refactor: extracted OIDC user lookup and token verify from BearerToke…
GPortas Oct 30, 2024
ba70a04
Added: unit tests to newly added methods in AuthenticationServiceBean
GPortas Oct 30, 2024
80ad5a4
Stash: implementing users/register endpoint WIP
GPortas Nov 1, 2024
2ca0722
Added: user creation logic to RegisterOidcUserCommand and missing fie…
GPortas Nov 1, 2024
e382a15
Refactor: extracted response messages to Bundle.properties
GPortas Nov 1, 2024
fd68cd2
Refactor: error message string extracted to const
GPortas Nov 1, 2024
6a7a3e1
Changed: replaced string class constants with Bundle.properties strings
GPortas Nov 1, 2024
424dcbf
Merge branch 'develop' of github.com:IQSS/dataverse into 10959-bearer…
GPortas Nov 4, 2024
3539677
Added: managing user terms acceptance in registration
GPortas Nov 4, 2024
43805f0
Refactor: registerOidcUser endpoint
GPortas Nov 4, 2024
63790db
Refactor: getRequestBearerToken extracted to AuthUtil
GPortas Nov 4, 2024
37afa98
Fixed: priority order in CompoundAuthMechanism to allow session and b…
GPortas Nov 4, 2024
0041552
Added: completed UserDTO fields
GPortas Nov 4, 2024
a021c9b
Added: json parse logic for register user
GPortas Nov 5, 2024
bf601e6
Added: fields validation to RegisterOidcUserCommand
GPortas Nov 6, 2024
6083982
Merge branch 'develop' of github.com:IQSS/dataverse into 10959-bearer…
GPortas Nov 6, 2024
e544221
Changed: Bundle.properties values
GPortas Nov 6, 2024
753f6eb
Added: unit tests for RegisterOidcUserCommand
GPortas Nov 8, 2024
86355e3
Merge branch 'develop' of github.com:IQSS/dataverse into 10959-bearer…
GPortas Nov 8, 2024
15b78bb
Removed: unnecessary auth annotation on register endpoint
GPortas Nov 8, 2024
415e23b
Added: users register endpoint IT and fixes
GPortas Nov 8, 2024
b1901c2
Fixed: users register endpoint response body structure when there are…
GPortas Nov 8, 2024
fadebca
Added: test assertions in UsersIT for register endpoint
GPortas Nov 8, 2024
b993ba1
Changed: handling more specific response messages on command Permissi…
GPortas Nov 11, 2024
32f5fec
Changed: test-realm.json to include new admin role in test realm nece…
GPortas Nov 11, 2024
c0c6704
Added: new test cases for registerOidcUser
GPortas Nov 11, 2024
a064a7b
Removed: unused imports
GPortas Nov 11, 2024
672582a
Merge branch 'develop' of github.com:IQSS/dataverse into 10959-bearer…
GPortas Nov 11, 2024
4bc58f6
Fixed: OIDCAuthenticationProviderFactoryIT
GPortas Nov 11, 2024
4536f91
Added: release notes for #10959
GPortas Nov 11, 2024
99ce940
Changed: release notes tweaks
GPortas Nov 11, 2024
386b6ac
Added: new OidcUserInfo object for encapsulating both User record ide…
GPortas Nov 13, 2024
b5d40ad
Refactor: renamed classes and methods from 'Oidc/oidc' to 'OIDC' to b…
GPortas Nov 13, 2024
dce7edf
Changed: using claims as UserDTO fields when available from the IdP
GPortas Nov 13, 2024
9a62528
Added: API_BEARER_AUTH_JSON_CLAIMS feature flag
GPortas Nov 15, 2024
396aaa4
Merge branch 'develop' of github.com:IQSS/dataverse into 10959-bearer…
GPortas Nov 15, 2024
0f2cfdc
Changed: renamed flag API_BEARER_AUTH_PROVIDE_MISSING_CLAIMS
GPortas Nov 15, 2024
52a5a9e
Added: API_BEARER_AUTH_PROVIDE_MISSING_CLAIMS management an different…
GPortas Nov 15, 2024
047a14c
Fixed: RegisterOIDCUserCommandTest
GPortas Nov 15, 2024
cc86a83
Added: explanatory comment tweak
GPortas Nov 15, 2024
c3de7d7
Added: DATAVERSE_FEATURE_API_BEARER_AUTH_PROVIDE_MISSING_CLAIMS enabl…
GPortas Nov 15, 2024
25cdf98
Fixed: UsersIT registerOidcUser
GPortas Nov 15, 2024
5d39ac1
Added: #10959 docs to auth.rst
GPortas Nov 15, 2024
a438c8a
Added: docs for #10959
GPortas Nov 15, 2024
9ba377e
Fixed: doc tweak
GPortas Nov 15, 2024
b00ac7f
Changed: replaced version TODO with 5.14 for api-bearer-auth feature …
GPortas Nov 15, 2024
c269d3c
Merge branch 'develop' of github.com:IQSS/dataverse into 10959-bearer…
GPortas Nov 19, 2024
73c4079
Changed: simpler statement in auth.rst
GPortas Nov 19, 2024
f99732b
Changed: doc tweak in auth.rst
GPortas Nov 19, 2024
dbfe40d
Refactor: registerOidcUserCommand Bundle strings
GPortas Nov 19, 2024
7495364
Merge branch 'develop' of github.com:IQSS/dataverse into 10959-bearer…
GPortas Nov 20, 2024
4ae119c
Changed: throwing an error when registering an OIDC user and attempti…
GPortas Nov 20, 2024
335e40a
Changed: doc tweak for api-bearer-auth-json-claims
GPortas Nov 20, 2024
4ca6070
Refactor: using OAuth2UserRecord instead of OIDCUserInfo
GPortas Nov 20, 2024
07794f3
Removed: unused OIDCUserInfo
GPortas Nov 20, 2024
7d88c8e
Added: release notes for #10959
GPortas Nov 20, 2024
ae58595
Removed: duplicated release notes doc
GPortas Nov 20, 2024
f360b91
Changed: checking when claim is blank in the provider in RegisterOIDC…
GPortas Nov 21, 2024
16f8e04
Added: validate user DTO has no claims when feature flag is disabled
GPortas Nov 21, 2024
cc99a8b
Added: test case to RegisterOIDCUserCommandTest for blank claim values
GPortas Nov 21, 2024
353da1d
chore: update docs
g-saracca Dec 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
672 changes: 398 additions & 274 deletions conf/keycloak/test-realm.json

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions doc/release-notes/10959-bearer-token-user-registration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
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.
23 changes: 23 additions & 0 deletions doc/sphinx-guides/source/api/auth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
GPortas marked this conversation as resolved.
Show resolved Hide resolved

.. 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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What terms? Is it https://guides.dataverse.org/en/latest/api/native-api.html#get-api-terms-of-use-url - if so can that be referenced here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here, I am referring to the terms of use of the installation, the ones present in the sign-up form and are configurable in the installation.

Screenshot 2024-11-19 at 12 21 37

Screenshot 2024-11-19 at 12 26 11

I have rephrased the text to: "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."

I don't think I can provide any links to the terms as they are embedded and not present by default.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However, would it make sense to add a property per OIDC provider that automatically sets this value to true? I'm thinking of internal institutional installations where its acceptance is implicit or already covered during account creation.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. I think some config option like that for terms, affiliation, and role would make sense, but I'd definitely suggest a new PR rather holding this up and making it even bigger.


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.
qqmyers marked this conversation as resolved.
Show resolved Hide resolved

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 identity provider will be used instead.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understood, in the comments, you suggested that ignoring the values could be confusing. Would it be better to just fail if you send info that is already in the token? (+1 from me)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. I just updated it.


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
-----------

Expand Down
6 changes: 6 additions & 0 deletions doc/sphinx-guides/source/installation/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/IQSS/dataverse/issues/9063>`_) and for the feature to be removed in the future.
- ``Off``
* - api-bearer-auth
qqmyers marked this conversation as resolved.
Show resolved Hide resolved
- 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``
Expand Down
1 change: 1 addition & 0 deletions docker-compose-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
36 changes: 29 additions & 7 deletions src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -631,10 +629,22 @@ protected <T> T execCommand( Command<T> 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) {
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()));
Expand Down Expand Up @@ -809,6 +819,18 @@ protected Response badRequest( String msg ) {
return error( Status.BAD_REQUEST, msg );
}

protected Response badRequest(String msg, Map<String, String> fieldErrors) {
return Response.status(Status.BAD_REQUEST)
.entity(NullSafeJsonBuilder.jsonObjectBuilder()
.add("status", ApiConstants.STATUS_ERROR)
.add("message", msg)
.add("fieldErrors", Json.createObjectBuilder(fieldErrors).build())
.build()
)
.type(MediaType.APPLICATION_JSON_TYPE)
.build();
}

protected Response forbidden( String msg ) {
return error( Status.FORBIDDEN, msg );
}
Expand Down
42 changes: 33 additions & 9 deletions src/main/java/edu/harvard/iq/dataverse/api/Users.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,33 @@
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.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.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.extractBearerTokenFromHeaderParam;
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;
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.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.*;

/**
*
Expand Down Expand Up @@ -266,4 +270,24 @@ public Response getTracesElement(@Context ContainerRequestContext crc, @Context
}
}

@POST
@Path("register")
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<String> bearerToken = extractBearerTokenFromHeaderParam(httpRequest.getHeader(HttpHeaders.AUTHORIZATION));
if (bearerToken.isEmpty()) {
return error(Response.Status.BAD_REQUEST, BundleUtil.getStringFromBundle("users.api.errors.bearerTokenRequired"));
}
try {
JsonObject userJson = JsonUtil.getJsonObject(body);
qqmyers marked this conversation as resolved.
Show resolved Hide resolved
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) {
return e.getResponse();
}
return ok(BundleUtil.getStringFromBundle("users.api.userRegistered"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import jakarta.inject.Inject;
import jakarta.ws.rs.container.ContainerRequestContext;

import java.util.logging.Logger;

/**
Expand Down Expand Up @@ -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) {
Expand All @@ -59,15 +60,15 @@ 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;
}
// For privateUrlUsers restricted to anonymized access, all api calls are off-limits except for those used in the UI
// 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);
}
}
}
24 changes: 24 additions & 0 deletions src/main/java/edu/harvard/iq/dataverse/api/auth/AuthUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package edu.harvard.iq.dataverse.api.auth;

import java.util.Optional;

public class AuthUtil {

private static final String BEARER_AUTH_SCHEME = "Bearer";

/**
* Extracts the Bearer token from the provided HTTP Authorization header value.
* <p>
* 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.
*
* @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<String> extractBearerTokenFromHeaderParam(String headerParamBearerToken) {
if (headerParamBearerToken != null && headerParamBearerToken.toLowerCase().startsWith(BEARER_AUTH_SCHEME.toLowerCase() + " ")) {
return Optional.of(headerParamBearerToken);
}
return Optional.empty();
}
}
Loading
Loading