diff --git a/backend/pom.xml b/backend/pom.xml index c7a3e616d9..22cacffd34 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -244,6 +244,11 @@ + + com.password4j + password4j + 1.7.3 + io.dropwizard dropwizard-views-freemarker diff --git a/backend/src/main/java/com/bakdata/conquery/apiv1/auth/ApiTokenDataRepresentation.java b/backend/src/main/java/com/bakdata/conquery/apiv1/auth/ApiTokenDataRepresentation.java deleted file mode 100644 index a95156501c..0000000000 --- a/backend/src/main/java/com/bakdata/conquery/apiv1/auth/ApiTokenDataRepresentation.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.bakdata.conquery.apiv1.auth; - -import com.bakdata.conquery.io.storage.MetaStorage; -import com.bakdata.conquery.models.auth.apitoken.ApiTokenData; -import com.bakdata.conquery.models.auth.apitoken.ApiTokenRealm; -import com.bakdata.conquery.models.auth.apitoken.Scopes; -import com.bakdata.conquery.models.auth.entities.User; -import com.fasterxml.jackson.annotation.JsonIgnore; -import io.dropwizard.validation.ValidationMethod; -import lombok.*; -import lombok.experimental.Accessors; - -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.NotNull; -import java.time.LocalDate; -import java.util.Set; -import java.util.UUID; - -/** - * Container class for how tokens are represented through the API. - * This is necessary so that the actual token and it's hash are not leaked (with except for the token on creation). - * @implNote We don't use fluent accessors here, because that does not work well with Jackson - */ -@Data -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public abstract class ApiTokenDataRepresentation { - - @NotNull - protected String name; - @NotNull - protected LocalDate expirationDate; - @NotEmpty - protected Set scopes; - - @ValidationMethod - @JsonIgnore - boolean isNotExpired() { - final LocalDate now = LocalDate.now(); - return expirationDate.isAfter(now) || expirationDate.isEqual(now); - } - - /** - * Container that is send with an incoming request to create a token. - */ - @Data - @EqualsAndHashCode(callSuper = true) - public static class Request extends ApiTokenDataRepresentation { - // Intentionally left blank - } - - /** - * Container that is send with an outgoing response to give information about created tokens. - */ - @Data - @EqualsAndHashCode(callSuper = true) - public static class Response extends ApiTokenDataRepresentation { - - private UUID id; - private LocalDate lastUsed; - private LocalDate creationDate; - private boolean isExpired; - - } -} diff --git a/backend/src/main/java/com/bakdata/conquery/apiv1/auth/PasswordCredential.java b/backend/src/main/java/com/bakdata/conquery/apiv1/auth/PasswordCredential.java index 31f161579e..1d44661943 100644 --- a/backend/src/main/java/com/bakdata/conquery/apiv1/auth/PasswordCredential.java +++ b/backend/src/main/java/com/bakdata/conquery/apiv1/auth/PasswordCredential.java @@ -3,23 +3,13 @@ import javax.validation.constraints.NotEmpty; import com.bakdata.conquery.io.cps.CPSType; -import com.bakdata.conquery.models.config.auth.AuthorizationConfig; import com.bakdata.conquery.models.auth.basic.LocalAuthenticationRealm; -import com.fasterxml.jackson.annotation.JsonCreator; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.RequiredArgsConstructor; +import com.bakdata.conquery.models.config.auth.AuthorizationConfig; /** - * Container for holding a password. This credential type is used by the - * {@link LocalAuthenticationRealm} and can be used in the {@link AuthorizationConfig}. + * Container for holding a plain-text password. This credential type is used by the + * {@link LocalAuthenticationRealm} and can be used in the {@link AuthorizationConfig}. */ @CPSType(base = CredentialType.class, id = "PASSWORD") -@Data -@RequiredArgsConstructor(onConstructor = @__({@JsonCreator})) -@AllArgsConstructor -public class PasswordCredential implements CredentialType { - - @NotEmpty - private char[] password; +public record PasswordCredential(@NotEmpty String password) implements CredentialType { } diff --git a/backend/src/main/java/com/bakdata/conquery/apiv1/auth/PasswordHashCredential.java b/backend/src/main/java/com/bakdata/conquery/apiv1/auth/PasswordHashCredential.java new file mode 100644 index 0000000000..04d4bc009f --- /dev/null +++ b/backend/src/main/java/com/bakdata/conquery/apiv1/auth/PasswordHashCredential.java @@ -0,0 +1,9 @@ +package com.bakdata.conquery.apiv1.auth; + +import javax.validation.constraints.NotEmpty; + +import com.bakdata.conquery.io.cps.CPSType; + +@CPSType(base = CredentialType.class, id = "PASSWORD_HASH") +public record PasswordHashCredential(@NotEmpty String hash) implements CredentialType { +} diff --git a/backend/src/main/java/com/bakdata/conquery/apiv1/auth/ProtoUser.java b/backend/src/main/java/com/bakdata/conquery/apiv1/auth/ProtoUser.java index 6d57ee44de..5803a98b58 100644 --- a/backend/src/main/java/com/bakdata/conquery/apiv1/auth/ProtoUser.java +++ b/backend/src/main/java/com/bakdata/conquery/apiv1/auth/ProtoUser.java @@ -1,7 +1,6 @@ package com.bakdata.conquery.apiv1.auth; import java.util.Collections; -import java.util.List; import java.util.Set; import javax.validation.Valid; @@ -45,9 +44,8 @@ public class ProtoUser { * {@link UserManageable}, such as {@link LocalAuthenticationRealm}). */ @Builder.Default - @NotNull @Valid - private List credentials = Collections.emptyList(); + private CredentialType credential = null; public User createOrOverwriteUser(@NonNull MetaStorage storage) { if (label == null) { diff --git a/backend/src/main/java/com/bakdata/conquery/apiv1/auth/UsernamePasswordToken.java b/backend/src/main/java/com/bakdata/conquery/apiv1/auth/UsernamePasswordToken.java index 2c759685b1..ba43c430f8 100644 --- a/backend/src/main/java/com/bakdata/conquery/apiv1/auth/UsernamePasswordToken.java +++ b/backend/src/main/java/com/bakdata/conquery/apiv1/auth/UsernamePasswordToken.java @@ -17,5 +17,5 @@ public class UsernamePasswordToken { @NotEmpty private String user; @NotEmpty - private char[] password; + private String password; } diff --git a/backend/src/main/java/com/bakdata/conquery/models/auth/AuthorizationController.java b/backend/src/main/java/com/bakdata/conquery/models/auth/AuthorizationController.java index 266d565ce5..e95307e39a 100644 --- a/backend/src/main/java/com/bakdata/conquery/models/auth/AuthorizationController.java +++ b/backend/src/main/java/com/bakdata/conquery/models/auth/AuthorizationController.java @@ -145,7 +145,7 @@ private static void initializeAuthConstellation(@NonNull AuthorizationConfig con final User user = pUser.createOrOverwriteUser(storage); for (Realm realm : realms) { if (realm instanceof UserManageable) { - AuthorizationHelper.registerForAuthentication((UserManageable) realm, user, pUser.getCredentials(), true); + AuthorizationHelper.registerForAuthentication((UserManageable) realm, user, pUser.getCredential(), true); } } } diff --git a/backend/src/main/java/com/bakdata/conquery/models/auth/AuthorizationHelper.java b/backend/src/main/java/com/bakdata/conquery/models/auth/AuthorizationHelper.java index 766b3abf54..788e856cc7 100644 --- a/backend/src/main/java/com/bakdata/conquery/models/auth/AuthorizationHelper.java +++ b/backend/src/main/java/com/bakdata/conquery/models/auth/AuthorizationHelper.java @@ -1,6 +1,5 @@ package com.bakdata.conquery.models.auth; -import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -14,8 +13,8 @@ import com.bakdata.conquery.io.storage.MetaStorage; import com.bakdata.conquery.models.auth.entities.Group; import com.bakdata.conquery.models.auth.entities.Role; -import com.bakdata.conquery.models.auth.entities.User; import com.bakdata.conquery.models.auth.entities.Subject; +import com.bakdata.conquery.models.auth.entities.User; import com.bakdata.conquery.models.auth.permissions.Ability; import com.bakdata.conquery.models.auth.permissions.ConqueryPermission; import com.bakdata.conquery.models.datasets.Dataset; @@ -129,7 +128,7 @@ public static Map> buildDatasetAbilityMap(Subject subjec } - public static boolean registerForAuthentication(UserManageable userManager, User user, List credentials, boolean override) { + public static boolean registerForAuthentication(UserManageable userManager, User user, CredentialType credentials, boolean override) { if(override) { return userManager.updateUser(user, credentials); } diff --git a/backend/src/main/java/com/bakdata/conquery/models/auth/UserManageable.java b/backend/src/main/java/com/bakdata/conquery/models/auth/UserManageable.java index 8430603720..8af25184e7 100644 --- a/backend/src/main/java/com/bakdata/conquery/models/auth/UserManageable.java +++ b/backend/src/main/java/com/bakdata/conquery/models/auth/UserManageable.java @@ -20,13 +20,13 @@ public interface UserManageable { * @param credentials A List of credentials that are provided by the user. * @return True upon successful adding of the user. False if the user could not be added or was already present. */ - boolean addUser(User user, List credentials); + boolean addUser(User user, CredentialType credential); /** * Similar to {@link UserManageable#addUser(User, List)} but if the user already existed it is overridden, when a fitting {@link CredentialType} was found. */ - boolean updateUser(User user, List credentials); + boolean updateUser(User user, CredentialType credential); /** * Removes a user from the realm only but not from the local permission storage (i.e. {@link MetaStorage}). diff --git a/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/ApiToken.java b/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/ApiToken.java deleted file mode 100644 index 15df971cda..0000000000 --- a/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/ApiToken.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.bakdata.conquery.models.auth.apitoken; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonIgnore; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.RequiredArgsConstructor; -import lombok.Setter; -import org.apache.http.util.CharArrayBuffer; -import org.apache.shiro.authc.AuthenticationToken; - -import javax.crypto.SecretKeyFactory; -import javax.crypto.spec.PBEKeySpec; -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.NotNull; -import java.nio.CharBuffer; -import java.security.NoSuchAlgorithmException; -import java.security.spec.InvalidKeySpecException; -import java.util.Arrays; -import java.util.UUID; - -/** - * Container for a sensitive token that allows to use the APIs (see {@link ApiTokenRealm}) - * This object only carries the authenticating part of the token. The authorizing part is within a corresponding - * {@link ApiTokenData} object. - * - * @implNote After the token is processed, its buffer must be cleared to avoid leakage. Conquery has registered the - * {@link com.bakdata.conquery.io.jackson.serializer.CharArrayBufferSerializer} to post the token once to the user - * through the API. Be aware, that once this object was serialized, its token is cleared. - */ -@RequiredArgsConstructor(onConstructor = @__(@JsonCreator)) -@Getter -public class ApiToken implements AuthenticationToken { - - // Statics for token hashing - private static final String ALGORITHM = "PBKDF2WithHmacSHA1"; - private static final int ITERATIONS = 10000; - private static final int KEY_LENGTH = 256; - private static final byte[] SALT = {'s','t','a','t','i','c','_','s','a','l','t'}; - - @NotNull - @NotEmpty - private final CharArrayBuffer token; - - /** - * Id of this token for identification by the user. - * This field is only set for tokens that will be serialized. - * On incoming tokens this field will be null as it won't be submitted in the - * authorization header. - */ - @Setter - private UUID id; - - @Override - @JsonIgnore - public CharArrayBuffer getPrincipal() { - return token; - } - - @Override - @JsonIgnore - public CharArrayBuffer getCredentials() { - return token; - } - - - public void clear() { - Arrays.fill(token.buffer(), '\0'); - } - - - - /** - * Hashes only the {@link ApiToken#token} - * @param apiToken - * @return - */ - public ApiTokenHash hashToken(){ - PBEKeySpec spec = new PBEKeySpec(getCredentials().buffer(), SALT, ITERATIONS, KEY_LENGTH); - SecretKeyFactory f = null; - try { - f = SecretKeyFactory.getInstance(ALGORITHM); - return new ApiTokenHash(f.generateSecret(spec).getEncoded()); - } - catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("The indicated algorithm was not found", e); - } - catch (InvalidKeySpecException e) { - throw new IllegalStateException("The key specification was invalid", e); - } - } -} diff --git a/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/ApiTokenCreator.java b/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/ApiTokenCreator.java deleted file mode 100644 index ef48f4f615..0000000000 --- a/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/ApiTokenCreator.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.bakdata.conquery.models.auth.apitoken; - -import lombok.Data; -import org.apache.http.util.CharArrayBuffer; -import org.jetbrains.annotations.TestOnly; - -import javax.crypto.SecretKeyFactory; -import javax.crypto.spec.PBEKeySpec; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.security.spec.InvalidKeySpecException; -import java.util.Random; - -/** - * Provider for random generated API tokens. - * - * Use a pseudo random generator for test purpose only. - */ -@Data -public class ApiTokenCreator { - public static final int TOKEN_LENGTH = 37; // GitHub uses 37 alphanumerics for their token - public static final String TOKEN_PREFIX = "cq"; // short for conquery - - private final PrintableASCIIProvider tokenProvider; - - public ApiTokenCreator() { - this(new SecureRandom()); - } - - @TestOnly - public ApiTokenCreator(Random random) { - tokenProvider = new PrintableASCIIProvider(random); - } - - - - public ApiToken createToken(){ - CharArrayBuffer buffer = new CharArrayBuffer(TOKEN_PREFIX.length() + "_".length() + TOKEN_LENGTH); - buffer.append(TOKEN_PREFIX); - buffer.append('_'); - tokenProvider.fillRemaining(buffer); - return new ApiToken(buffer); - } -} diff --git a/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/ApiTokenData.java b/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/ApiTokenData.java deleted file mode 100644 index 757bf896dc..0000000000 --- a/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/ApiTokenData.java +++ /dev/null @@ -1,117 +0,0 @@ -package com.bakdata.conquery.models.auth.apitoken; - -import java.time.LocalDate; -import java.util.Set; -import java.util.UUID; - -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.NotNull; - -import com.bakdata.conquery.io.storage.MetaStorage; -import com.bakdata.conquery.models.auth.entities.User; -import com.bakdata.conquery.models.auth.permissions.Ability; -import com.bakdata.conquery.models.auth.permissions.AdminPermission; -import com.bakdata.conquery.models.auth.permissions.Authorized; -import com.bakdata.conquery.models.auth.permissions.ConqueryPermission; -import com.bakdata.conquery.models.execution.Owned; -import com.bakdata.conquery.models.identifiable.ids.specific.UserId; -import com.fasterxml.jackson.annotation.JacksonInject; -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.OptBoolean; -import com.google.common.collect.ImmutableSet; -import lombok.Data; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.NonNull; - -@Getter -public class ApiTokenData implements Authorized, Owned { - - /** - * The id is used to reference the token from outside the realm, i.e. the API. - */ - private final UUID id; - /** - * The hash is the hashed API-token and not the hash of this object. - * It is only used internally in the realm to get a mapping back to the key of this object. - * This is used when a token is deleted: - * - The api provides the token id (UUID) for the token that needs to be deleted - * - The realm queries the storage for the {@link ApiTokenData} with that id - * - The realm gets the token hash from the data - * - The realm uses this token hash to delete the data from the store - */ - @NonNull - private final ApiTokenHash tokenHash; - @NonNull - private final String name; - @NotNull - private final UserId userId; - @NonNull - private final LocalDate creationDate; - /** - * The expiration date can be null: infinite - */ - private final LocalDate expirationDate; - @NotEmpty - private final Set scopes; - - @JsonCreator - private ApiTokenData(UUID id, @NonNull ApiTokenHash tokenHash, @NonNull String name, @NotNull UserId userId, @NonNull LocalDate creationDate, LocalDate expirationDate, @NotEmpty Set scopes) { - this(id, tokenHash, name, userId, creationDate, expirationDate, scopes, null); - } - - public ApiTokenData(UUID id, @NonNull ApiTokenHash tokenHash, @NonNull String name, @NotNull UserId userId, @NonNull LocalDate creationDate, LocalDate expirationDate, @NotEmpty Set scopes, @NotNull MetaStorage storage) { - this.id = id; - this.tokenHash = tokenHash; - this.name = name; - this.userId = userId; - this.creationDate = creationDate; - this.expirationDate = expirationDate; - this.scopes = scopes; - this.storage = storage; - } - - /** - * @implNote This is the only member that would be mutable otherwise. - */ - public Set getScopes() { - return ImmutableSet.copyOf(scopes); - } - - @JacksonInject(useInput = OptBoolean.FALSE) - @NotNull - @EqualsAndHashCode.Exclude - @JsonIgnore - private final MetaStorage storage; - - - @Override - public ConqueryPermission createPermission(Set abilities) { - return AdminPermission.onDomain(); - } - - @Override - @JsonIgnore - public User getOwner() { - return storage.getUser(userId); - } - - /** - * Dynamic information about the token - */ - @Data - public static class MetaData { - @NotNull - private final LocalDate lastUsed; - } - - public boolean isCoveredByScopes(ConqueryPermission permission) { - for (Scopes scope : scopes) { - if (scope.isPermissionInScope(permission)) { - return true; - } - } - return false; - } -} diff --git a/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/ApiTokenHash.java b/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/ApiTokenHash.java deleted file mode 100644 index 7095fcdfb4..0000000000 --- a/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/ApiTokenHash.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.bakdata.conquery.models.auth.apitoken; - -import java.util.Arrays; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.google.common.base.Joiner; -import lombok.Data; -import lombok.RequiredArgsConstructor; - -/** - * An erasable hash for tokens that implements equals based on its contents - */ -@Data -@RequiredArgsConstructor(onConstructor = @__(@JsonCreator)) -public class ApiTokenHash { - private final byte[] hash; - - @Override - public boolean equals(Object obj) { - if (obj == null) { - return false; - } - if (!ApiTokenHash.class.isAssignableFrom(obj.getClass())) { - return false; - } - return Arrays.equals(hash, ((ApiTokenHash) obj).hash); - } - - public int hashCode() { - return Arrays.hashCode(hash); - } - - public void clear() { - Arrays.fill(hash, (byte) 0); - } -} diff --git a/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/ApiTokenRealm.java b/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/ApiTokenRealm.java deleted file mode 100644 index 07c32f016f..0000000000 --- a/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/ApiTokenRealm.java +++ /dev/null @@ -1,183 +0,0 @@ -package com.bakdata.conquery.models.auth.apitoken; - -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -import javax.validation.constraints.NotNull; - -import com.bakdata.conquery.apiv1.auth.ApiTokenDataRepresentation; -import com.bakdata.conquery.io.storage.MetaStorage; -import com.bakdata.conquery.models.auth.ConqueryAuthenticationInfo; -import com.bakdata.conquery.models.auth.ConqueryAuthenticationRealm; -import com.bakdata.conquery.models.auth.entities.Subject; -import com.bakdata.conquery.models.auth.entities.User; -import com.bakdata.conquery.models.auth.permissions.Ability; -import com.bakdata.conquery.models.auth.util.SkippingCredentialsMatcher; -import com.bakdata.conquery.models.identifiable.ids.specific.UserId; -import lombok.NonNull; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.tuple.Pair; -import org.apache.shiro.authc.AuthenticationException; -import org.apache.shiro.authc.AuthenticationToken; -import org.apache.shiro.authc.ExpiredCredentialsException; -import org.apache.shiro.authc.IncorrectCredentialsException; -import org.apache.shiro.authc.UnknownAccountException; -import org.apache.shiro.realm.AuthenticatingRealm; - - -/** - * This realm provides and checks long-living API tokens. The tokens support a limited scope of actions that is backed - * by the actual permissions of the invoking user. - * - */ -@Slf4j -public class ApiTokenRealm extends AuthenticatingRealm implements ConqueryAuthenticationRealm { - - private final MetaStorage storage; - private final TokenStorage tokenStorage; - - private final ApiTokenCreator apiTokenCreator = new ApiTokenCreator(); - - public ApiTokenRealm(MetaStorage storage, TokenStorage tokenStorage) { - this.storage = storage; - this.tokenStorage = tokenStorage; - this.setCredentialsMatcher(SkippingCredentialsMatcher.INSTANCE); - this.setAuthenticationTokenClass(ApiToken.class); - } - - @Override - public ConqueryAuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { - if (!(token instanceof ApiToken)) { - return null; - } - - final ApiToken apiToken = ((ApiToken) token); - ApiTokenHash tokenHash = apiToken.hashToken(); - - // Clear the token - apiToken.clear(); - - - ApiTokenData tokenData = tokenStorage.get(tokenHash); - if (tokenData == null) { - log.trace("Unknown token, cannot map token hash to token data. Aborting authentication"); - throw new IncorrectCredentialsException(); - } - - if (LocalDate.now().isAfter(tokenData.getExpirationDate())) { - log.info("Supplied token expired on: {}", tokenData.getExpirationDate()); - throw new ExpiredCredentialsException("Supplied token is expired"); - } - - final ApiTokenData.MetaData metaData = new ApiTokenData.MetaData(LocalDate.now()); - tokenStorage.updateMetaData(tokenData.getId(), metaData); - - final UserId userId = tokenData.getUserId(); - final User user = storage.getUser(userId); - - if (user == null) { - throw new UnknownAccountException("The UserId does not map to a user: " + userId); - } - - return new ConqueryAuthenticationInfo(new TokenScopedUser(user, tokenData), token, this, false); - } - - - - public ApiToken createApiToken(User user, ApiTokenDataRepresentation.Request tokenRequest) { - - ApiToken token; - - synchronized (this) { - ApiTokenHash hash; - // Generate a token that does not collide with another tokens hash - do { - token = apiTokenCreator.createToken(); - hash = token.hashToken(); - - } while(tokenStorage.get(hash) != null); - - final ApiTokenData apiTokenData = toInternalRepresentation(tokenRequest, user, hash, storage); - - tokenStorage.add(hash, apiTokenData); - - token.setId(apiTokenData.getId()); - } - - - return token; - } - - public List listUserToken(Subject subject) { - ArrayList summary = new ArrayList<>(); - - for (Iterator> it = tokenStorage.getAll(); it.hasNext(); ) { - Pair apiToken = it.next(); - // Find all token data belonging to a user - final ApiTokenData data = apiToken.getKey(); - if (!subject.isOwner(data)){ - continue; - } - - // Fill in the response with the details - final ApiTokenDataRepresentation.Response response = new ApiTokenDataRepresentation.Response(); - response.setId(data.getId()); - response.setCreationDate(data.getCreationDate()); - response.setName(data.getName()); - response.setExpirationDate(data.getExpirationDate()); - response.setScopes(data.getScopes()); - response.setExpired(LocalDate.now().isAfter(data.getExpirationDate())); - - // If the token was ever used it should have an meta data entry - ApiTokenData.MetaData meta = apiToken.getValue(); - if (meta != null) { - response.setLastUsed(meta.getLastUsed()); - } - summary.add(response); - } - return summary; - } - - public void deleteToken(@NotNull Subject subject, @NonNull UUID tokenId) { - - Optional tokenOpt = tokenStorage.getByUUID(tokenId); - - if (tokenOpt.isEmpty()) { - log.warn("No token with id {} was found", tokenId); - return; - } - - - final ApiTokenData token = tokenOpt.get(); - - // Only the Owner or a subject with admin capabilities can delete a token - subject.authorize(token, Ability.DELETE); - - tokenStorage.deleteToken(token); - } - - - - private static ApiTokenData toInternalRepresentation( - ApiTokenDataRepresentation.Request apiTokenRequest, - User user, - ApiTokenHash hash, - MetaStorage storage) { - final UUID id = UUID.randomUUID(); - log.info("Creating new api token data for user {} with id: {}", user.getId(), id); - return new ApiTokenData( - id, - hash, - apiTokenRequest.getName(), - user.getId(), - LocalDate.now(), - apiTokenRequest.getExpirationDate(), - apiTokenRequest.getScopes(), - storage - ); - } -} diff --git a/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/PrintableASCIIProvider.java b/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/PrintableASCIIProvider.java deleted file mode 100644 index 29306d1988..0000000000 --- a/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/PrintableASCIIProvider.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.bakdata.conquery.models.auth.apitoken; - -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.CharUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.http.util.CharArrayBuffer; - -import java.nio.CharBuffer; -import java.util.Random; -import java.util.stream.Collectors; - -/** - * Fills a buffer with random printable ASCII characters that can be used for an {@link ApiToken}. - * See also {@link ApiTokenCreator}. - */ -@RequiredArgsConstructor -public class PrintableASCIIProvider { - private final Random random; - - public void fillRemaining(CharArrayBuffer buffer) { - final int remaining = buffer.capacity() - buffer.length(); - random.ints(0, 128) - .map(i -> { return (char) i;}) - .filter(this::isValidChar) - .limit(remaining) - .mapToObj(c -> (char) c) - .map(String::valueOf) - .forEach(buffer::append); - } - - private boolean isValidChar(int c) { - return CharUtils.isAsciiAlphanumeric((char ) c); - } - -} diff --git a/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/Scopes.java b/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/Scopes.java deleted file mode 100644 index 31aa584be3..0000000000 --- a/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/Scopes.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.bakdata.conquery.models.auth.apitoken; - -import com.bakdata.conquery.models.auth.permissions.*; -import com.fasterxml.jackson.annotation.JsonIgnore; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; - -import java.util.Set; - -/** - * Scopes to group permission types and restrict permissions of {@link ApiToken} (see {@link ApiTokenData}). - */ -@RequiredArgsConstructor -public enum Scopes { - /** - * Allows to use the admin interface - */ - ADMIN(Set.of(AdminPermission.DOMAIN)), - /** - * Allows to access entities of a dataset such as the dataset in general and its concepts. - */ - DATASET(Set.of(DatasetPermission.DOMAIN, ConceptPermission.DOMAIN)), - /** - * Allows to create and use execution related entities such as Queries and {@link com.bakdata.conquery.models.forms.configs.FormConfig}s - */ - EXECUTIONS(Set.of(ExecutionPermission.DOMAIN, FormConfigPermission.DOMAIN, FormPermission.DOMAIN)); - - @NonNull - @JsonIgnore - private final Set scopeDomains; - - /** - * The permission is covered by the scope if all domains in the permission - * are covered by the scope's domains. - * @param permission the permission to test. - * @return True, if all domains are supported by the scope. - * - * @implNote At the moment we use only one domain per permission. - */ - boolean isPermissionInScope(@NonNull ConqueryPermission permission) { - return scopeDomains.containsAll(permission.getDomains()); - } -} diff --git a/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/TokenScopedUser.java b/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/TokenScopedUser.java deleted file mode 100644 index ac060167ce..0000000000 --- a/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/TokenScopedUser.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.bakdata.conquery.models.auth.apitoken; - -import com.bakdata.conquery.models.auth.ConqueryAuthenticationInfo; -import com.bakdata.conquery.models.auth.entities.User; -import com.bakdata.conquery.models.auth.entities.Subject; -import com.bakdata.conquery.models.auth.permissions.Ability; -import com.bakdata.conquery.models.auth.permissions.Authorized; -import com.bakdata.conquery.models.auth.permissions.ConqueryPermission; -import com.bakdata.conquery.models.identifiable.ids.specific.UserId; -import lombok.Getter; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; -import org.apache.shiro.authz.UnauthorizedException; - -import java.util.Collection; -import java.util.EnumSet; -import java.util.List; -import java.util.Set; - -/** - * This subject combines a {@link User} with a belonging {@link ApiTokenData}. Permissions for an authorization request are filtered based on the {@link Scopes} of the token. - * If a permission is covered by a scope the authorization is delegated to the actual user. - */ -@RequiredArgsConstructor -@Getter -public class TokenScopedUser implements Subject { - - private final User delegate; - private final ApiTokenData tokenContext; - - @Override - public UserId getId() { - return delegate.getId(); - } - - @Override - public void authorize(@NonNull Authorized object, @NonNull Ability ability) { - final ConqueryPermission permission = object.createPermission(EnumSet.of(ability)); - if(!tokenContext.isCoveredByScopes(permission)) { - throw new UnauthorizedException("The scopes of the token do not support handling the permission: " + permission); - } - delegate.authorize(object,ability); - } - - @Override - public void authorize(Set objects, Ability ability) { - final EnumSet abilityEnumSet = EnumSet.of(ability); - if(!objects.stream().map(o -> o.createPermission(abilityEnumSet)).allMatch(tokenContext::isCoveredByScopes)) { - throw new UnauthorizedException("The scopes of the tokens do not support handling the permission"); - } - delegate.authorize(objects, ability); - } - - @Override - public boolean isPermitted(Authorized object, Ability ability) { - final ConqueryPermission permission = object.createPermission(EnumSet.of(ability)); - if(!tokenContext.isCoveredByScopes(permission)) { - return false; - } - return delegate.isPermitted(object, ability); - } - - @Override - public boolean isPermittedAll(Collection authorized, Ability ability) { - final EnumSet abilitySet = EnumSet.of(ability); - if(!authorized.stream().map(o -> o.createPermission(abilitySet)).allMatch(tokenContext::isCoveredByScopes)) { - return false; - } - return delegate.isPermittedAll(authorized,ability); - } - - @Override - public boolean[] isPermitted(List authorized, Ability ability) { - final EnumSet abilitySet = EnumSet.of(ability); - - boolean[] ret = new boolean[authorized.size()]; - for (int i = 0; i < ret.length; i++) { - Authorized object = authorized.get(i); - ret[i] = tokenContext.isCoveredByScopes(object.createPermission(abilitySet)) && - delegate.isPermitted(object, ability); - } - return ret; - } - - @Override - public boolean isOwner(Authorized object) { - return delegate.isOwner(object); - } - - @Override - public boolean isDisplayLogout() { - return false; - } - - @Override - public void setAuthenticationInfo(ConqueryAuthenticationInfo info) { - delegate.setAuthenticationInfo(info); - } - - @Override - public User getUser() { - return delegate; - } - - @Override - public String getName() { - return delegate.getName(); - } -} diff --git a/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/TokenStorage.java b/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/TokenStorage.java deleted file mode 100644 index 86b34d5267..0000000000 --- a/backend/src/main/java/com/bakdata/conquery/models/auth/apitoken/TokenStorage.java +++ /dev/null @@ -1,216 +0,0 @@ -package com.bakdata.conquery.models.auth.apitoken; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.Executors; - -import javax.validation.Validator; - -import com.bakdata.conquery.io.storage.Store; -import com.bakdata.conquery.io.storage.StoreMappings; -import com.bakdata.conquery.io.storage.xodus.stores.SerializingStore; -import com.bakdata.conquery.io.storage.xodus.stores.XodusStore; -import com.bakdata.conquery.models.config.XodusConfig; -import com.bakdata.conquery.util.io.FileUtil; -import com.fasterxml.jackson.databind.ObjectMapper; -import io.dropwizard.lifecycle.Managed; -import jetbrains.exodus.ExodusException; -import jetbrains.exodus.env.Environment; -import jetbrains.exodus.env.EnvironmentClosedException; -import jetbrains.exodus.env.Environments; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.tuple.Pair; - -/** - * Storage for immutable token data (the counter part to the {@link ApiToken}/{@link ApiTokenHash} that is presented to the user once) and - * the token meta data (which is updated on each usage of the token). - * - * @implNote While this has the two store layout resembles a {@link com.bakdata.conquery.io.storage.xodus.stores.BigStore}, a BigStore's meta store - * exists to hide a 1-n relationship (1 entry in meta, n entries in data) between a key an the chunks of its value. This store has a 1-1 relation ship between - * meta and data. The meta store is not used to store structural information but actual meta data about the token in the data store. - */ -@Slf4j -@RequiredArgsConstructor -public class TokenStorage implements Managed { - - private static final int ENVIRONMENT_CLOSING_RETRIES = 2; - private static final int ENVIRONMENT_CLOSING_TIMEOUT = 2; // seconds - - private final Path storageDir; - private final XodusConfig storeConfig; - private final Validator validator; - private final ObjectMapper objectMapper; - - private Environment environment; - private Store dataStore; - private Store metaDataStore; - private ArrayList openStoresInEnv = new ArrayList<>(); - - @Override - public void start(){ - String storeName = "api-token"; - File tokenStore = new File(storageDir.toFile(), storeName); - environment = Environments.newInstance(tokenStore, storeConfig.createConfig()); - - final XodusStore data = new XodusStore( - environment, - "DATA", - this::closeStoreHook, - this::removeStoreHook - ); - dataStore = StoreMappings.cached(new SerializingStore<>( - data, - validator, - objectMapper, - ApiTokenHash.class, - ApiTokenData.class, - true, - false, - null, Executors.newSingleThreadExecutor() - )); - openStoresInEnv.add(data); - - final XodusStore meta = new XodusStore( - environment, - "META", - this::closeStoreHook, - this::removeStoreHook - ); - metaDataStore = StoreMappings.cached(new SerializingStore<>( - meta, - validator, - objectMapper, - UUID.class, - ApiTokenData.MetaData.class, - true, - false, - null, Executors.newSingleThreadExecutor() - )); - openStoresInEnv.add(meta); - } - - - - private void removeStoreHook(XodusStore store) { - openStoresInEnv.remove(store); - - if (!openStoresInEnv.isEmpty()){ - return; - } - - final Environment environment = store.getEnvironment(); - log.info("Removed last XodusStore in Environment. Removing Environment as well: {}", environment.getLocation()); - - final List xodusStores= environment.computeInReadonlyTransaction(environment::getAllStoreNames); - - if (!xodusStores.isEmpty()){ - throw new IllegalStateException("Cannot delete environment, because it still contains these stores:" + xodusStores); - } - - environment.close(); - - try { - FileUtil.deleteRecursive(Path.of(environment.getLocation())); - } - catch (IOException e) { - log.error("Cannot delete directory of removed Environment[{}]", environment.getLocation(), e); - } - } - - private void closeStoreHook(XodusStore store) { - openStoresInEnv.remove(store); - final Environment environment = store.getEnvironment(); - if (!openStoresInEnv.isEmpty()){ - return; - } - if (!environment.isOpen()) { - return; - } - environment.close(); - } - - - - @Override - public void stop() throws Exception { - if (!environment.isOpen()) { - return; - } - for(int retries = 0; retries < ENVIRONMENT_CLOSING_RETRIES; retries++) { - try { - log.info("Closing the environment."); - environment.close(); - return; - } - catch (EnvironmentClosedException e) { - log.warn("Environment was already closed, which is odd but mayby the stop() lifecycle event fired twice"); - return; - } - catch (ExodusException e) { - if (retries == 0) { - log.info("The environment is still working on some transactions. Retry"); - } - log.info("Waiting for {} seconds to retry.", ENVIRONMENT_CLOSING_TIMEOUT); - Thread.sleep(ENVIRONMENT_CLOSING_TIMEOUT * 1000 /* milliseconds */); - } - } - // Close the environment with force - log.info("Closing the environment forcefully"); - environment.getEnvironmentConfig().setEnvCloseForcedly(true); - environment.close(); - - } - - public ApiTokenData get(ApiTokenHash tokenHash) { - return dataStore.get(tokenHash); - } - - public void updateMetaData(UUID id, ApiTokenData.MetaData metaData) { - metaDataStore.update(id, metaData); - } - - public void add(ApiTokenHash hash, ApiTokenData apiTokenData) { - dataStore.add(hash, apiTokenData); - } - - public Iterator> getAll() { - return dataStore.getAll().stream().map(tokenData -> Pair.of(tokenData, metaDataStore.get(tokenData.getId()))).iterator(); - } - - public Optional getByUUID(UUID tokenId) { - // Find the corresponding token data and extract its hash - for (ApiTokenData apiTokenData : dataStore.getAll()) { - if (tokenId.equals(apiTokenData.getId())) { - return Optional.of(apiTokenData); - } - } - return Optional.empty(); - } - - public void deleteToken(ApiTokenData token) { - - - final ApiTokenHash hash = token.getTokenHash(); - - synchronized (this) { - // This should never return null - ApiTokenData data = dataStore.get(hash); - if (data == null) { - throw new IllegalStateException("Unable to retrieve token data for hash."); - } - - - dataStore.remove(hash); - metaDataStore.remove(token.getId()); - } - - hash.clear(); - } -} diff --git a/backend/src/main/java/com/bakdata/conquery/models/auth/basic/AccessTokenCreator.java b/backend/src/main/java/com/bakdata/conquery/models/auth/basic/AccessTokenCreator.java index 833dd88a84..b5dceb3194 100644 --- a/backend/src/main/java/com/bakdata/conquery/models/auth/basic/AccessTokenCreator.java +++ b/backend/src/main/java/com/bakdata/conquery/models/auth/basic/AccessTokenCreator.java @@ -7,6 +7,6 @@ public interface AccessTokenCreator { * * @return A valid access token that authenticates the user that provided the credentials */ - String createAccessToken(String username, char[] password); + String createAccessToken(String username, String password); } \ No newline at end of file diff --git a/backend/src/main/java/com/bakdata/conquery/models/auth/basic/CredentialChecker.java b/backend/src/main/java/com/bakdata/conquery/models/auth/basic/CredentialChecker.java deleted file mode 100644 index cd31063ccc..0000000000 --- a/backend/src/main/java/com/bakdata/conquery/models/auth/basic/CredentialChecker.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.bakdata.conquery.models.auth.basic; - -import java.util.Arrays; - -import com.bakdata.conquery.io.storage.Store; -import com.bakdata.conquery.io.storage.xodus.stores.XodusStore; -import com.bakdata.conquery.models.auth.basic.PasswordHasher.HashedEntry; -import com.bakdata.conquery.models.identifiable.ids.specific.UserId; -import jetbrains.exodus.ByteIterable; -import jetbrains.exodus.bindings.StringBinding; -import lombok.experimental.UtilityClass; -import org.apache.shiro.authc.IncorrectCredentialsException; - -@UtilityClass -public class CredentialChecker { - - - /** - * Checks if the provided username password combination is valid. - * This is done by checking the password's hash with the stored hash. - * NOTE: After this operation the provided password is cleared. - * @param username The submitted username, here the email. - * @param providedPassword The submitted password - * @param passwordStore The store that holds the hashed passwords. - * @return True if the username-password combination is valid. - */ - public static boolean validUsernamePassword(String username, char[] providedPassword, Store passwordStore) { - try { - if(username.isEmpty()) { - throw new IncorrectCredentialsException("Username was empty"); - } - if(providedPassword.length < 1) { - throw new IncorrectCredentialsException("Password was empty"); - } - HashedEntry hashedEntry = passwordStore.get(new UserId(username)); - if(hashedEntry == null) { - return false; - } - return isCredentialValid(providedPassword, hashedEntry); - } - finally { - // Erase the provided password - Arrays.fill(providedPassword, '\0'); - } - } - - /** - * Hashes the provided credentials with the salt of the stored hash and compares both. - */ - public static boolean isCredentialValid(char[] providedCredentials, HashedEntry hashedEntry) { - byte[] hashFromProvided = PasswordHasher.generateHash(providedCredentials, hashedEntry.getSalt()); - return Arrays.equals(hashFromProvided, hashedEntry.getHash()); - } -} diff --git a/backend/src/main/java/com/bakdata/conquery/models/auth/basic/LocalAuthenticationRealm.java b/backend/src/main/java/com/bakdata/conquery/models/auth/basic/LocalAuthenticationRealm.java index e2686f2125..26ad9445c8 100644 --- a/backend/src/main/java/com/bakdata/conquery/models/auth/basic/LocalAuthenticationRealm.java +++ b/backend/src/main/java/com/bakdata/conquery/models/auth/basic/LocalAuthenticationRealm.java @@ -3,7 +3,6 @@ import java.io.File; import java.io.IOException; import java.util.List; -import java.util.Optional; import java.util.concurrent.Executors; import javax.validation.Validator; @@ -11,6 +10,7 @@ import com.bakdata.conquery.Conquery; import com.bakdata.conquery.apiv1.auth.CredentialType; import com.bakdata.conquery.apiv1.auth.PasswordCredential; +import com.bakdata.conquery.apiv1.auth.PasswordHashCredential; import com.bakdata.conquery.io.storage.MetaStorage; import com.bakdata.conquery.io.storage.Store; import com.bakdata.conquery.io.storage.StoreMappings; @@ -19,7 +19,7 @@ import com.bakdata.conquery.models.auth.ConqueryAuthenticationInfo; import com.bakdata.conquery.models.auth.ConqueryAuthenticationRealm; import com.bakdata.conquery.models.auth.UserManageable; -import com.bakdata.conquery.models.auth.basic.PasswordHasher.HashedEntry; +import com.bakdata.conquery.models.auth.basic.PasswordHasher.HashEntry; import com.bakdata.conquery.models.auth.conquerytoken.ConqueryTokenRealm; import com.bakdata.conquery.models.auth.entities.User; import com.bakdata.conquery.models.auth.util.SkippingCredentialsMatcher; @@ -28,16 +28,20 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.ImmutableList; -import com.google.common.collect.MoreCollectors; +import com.password4j.HashingFunction; +import com.password4j.Password; import io.dropwizard.util.Duration; import jetbrains.exodus.ExodusException; import jetbrains.exodus.env.Environment; import jetbrains.exodus.env.EnvironmentClosedException; import jetbrains.exodus.env.Environments; +import lombok.NonNull; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationToken; +import org.apache.shiro.authc.CredentialsException; +import org.apache.shiro.authc.IncorrectCredentialsException; import org.apache.shiro.realm.AuthenticatingRealm; import org.apache.shiro.util.Destroyable; @@ -57,7 +61,7 @@ public class LocalAuthenticationRealm extends AuthenticatingRealm implements Con private static final int ENVIRONMNENT_CLOSING_RETRYS = 2; private static final int ENVIRONMNENT_CLOSING_TIMEOUT = 2; // seconds - // Get the path for the storage here so it is set when as soon the first class is instantiated (in the ManagerNode) + // Get the path for the storage here, so it is set as soon the first class is instantiated (in the ManagerNode) // In the DistributedStandaloneCommand this directory is overriden multiple times before LocalAuthenticationRealm::onInit for the ShardNodes, so this is a problem. private final File storageDir; @@ -67,7 +71,7 @@ public class LocalAuthenticationRealm extends AuthenticatingRealm implements Con @JsonIgnore private Environment passwordEnvironment; @JsonIgnore - private Store passwordStore; + private Store passwordStore; @JsonIgnore private final ConqueryTokenRealm centralTokenRealm; @@ -75,11 +79,14 @@ public class LocalAuthenticationRealm extends AuthenticatingRealm implements Con private final Validator validator; private final ObjectMapper mapper; + private final HashingFunction defaultHashingFunction; + //////////////////// INITIALIZATION //////////////////// - public LocalAuthenticationRealm(Validator validator, ObjectMapper mapper, ConqueryTokenRealm centralTokenRealm, String storeName, File storageDir, XodusConfig passwordStoreConfig, Duration validDuration) { + public LocalAuthenticationRealm(Validator validator, ObjectMapper mapper, ConqueryTokenRealm centralTokenRealm, String storeName, File storageDir, XodusConfig passwordStoreConfig, Duration validDuration, HashingFunction defaultHashingFunction) { this.validator = validator; this.mapper = mapper; + this.defaultHashingFunction = defaultHashingFunction; this.setCredentialsMatcher(SkippingCredentialsMatcher.INSTANCE); this.storeName = storeName; this.storageDir = storageDir; @@ -106,7 +113,7 @@ protected void onInit() { validator, mapper, UserId.class, - PasswordHasher.HashedEntry.class, + HashEntry.class, false, true, null, Executors.newSingleThreadExecutor() @@ -126,66 +133,78 @@ public ConqueryAuthenticationInfo doGetAuthenticationInfo(AuthenticationToken to //////////////////// FOR USERNAME/PASSWORD - public String createAccessToken(String username, char[] password) { - // Check the password which is afterwards cleared - if (!CredentialChecker.validUsernamePassword(username, password, passwordStore)) { - throw new AuthenticationException("Provided username or password was not valid."); + public String createAccessToken(String username, String password) { + if (username.isEmpty()) { + throw new IncorrectCredentialsException("Username was empty"); + } + if (password.isEmpty()) { + throw new IncorrectCredentialsException("Password was empty"); + } + final UserId userId = new UserId(username); + HashEntry hashedEntry = passwordStore.get(userId); + if (hashedEntry == null) { + throw new CredentialsException("No password hash was found for user: " + username); + } + + final String hash = hashedEntry.getHash(); + if (!Password.check(password.getBytes(), hash.getBytes()).with(PasswordHelper.getHashingFunction(hash))) { + throw new IncorrectCredentialsException("Password was was invalid for user: " + userId); } - // The username is in this case the email - return centralTokenRealm.createTokenForUser(new UserId(username), validDuration); + + return centralTokenRealm.createTokenForUser(userId, validDuration); } /** * Converts the provided password to a Xodus compatible hash. */ - private static HashedEntry passwordToHashedEntry(PasswordCredential credential) { - return PasswordHasher.generateHashedEntry(credential.getPassword()); - } + private HashEntry toHashEntry(CredentialType credential) { - /** - * Checks the provided credentials for the realm-compatible - * {@link PasswordCredential}. However only one credential of this type is - * allowed to be provided. - * - * @param credentials - * A list of possible credentials. - * @return The password credential. - */ - private static Optional getTypePassword(List credentials) { - if(credentials == null) { - return Optional.empty(); + + if (credential instanceof PasswordCredential passwordCredential) { + return new HashEntry(Password.hash(passwordCredential.password()) + .with(defaultHashingFunction) + .getResult()); } - return credentials.stream() - .filter(PasswordCredential.class::isInstance) - .map(PasswordCredential.class::cast) - .collect(MoreCollectors.toOptional()); + else if (credential instanceof PasswordHashCredential passwordHashCredential) { + return new HashEntry(passwordHashCredential.hash()); + } + + throw new IllegalArgumentException("CredentialType not supported yet: " + credential.getClass()); } //////////////////// USER MANAGEMENT //////////////////// @Override - public boolean addUser(User user, List credentials) { - Optional optPassword = getTypePassword(credentials); - if (optPassword.isEmpty()) { - log.trace("No password credential provided. Not adding {} to {}", user.getName(), getName()); - return false; + public boolean addUser(@NonNull User user, @NonNull CredentialType credential) { + + try { + final HashEntry hashEntry = toHashEntry(credential); + passwordStore.add(user.getId(), hashEntry); + return true; } - HashedEntry passwordByteIt = optPassword.map(LocalAuthenticationRealm::passwordToHashedEntry).get(); - passwordStore.add(user.getId(), passwordByteIt); - return true; + catch (IllegalArgumentException e) { + log.warn("Unable to add user '{}'", user.getId(), e); + } + return false; } @Override - public boolean updateUser(User user, List credentials) { - Optional optPassword = getTypePassword(credentials); - if (optPassword.isEmpty()) { - log.trace("No password credential provided. Not adding {} to {}", user.getName(), getName()); + public boolean updateUser(User user, CredentialType credential) { + + if (credential == null) { + log.warn("Skipping user '{}' because no credential was provided", user.getId()); return false; } - HashedEntry passwordByteIt = optPassword.map(LocalAuthenticationRealm::passwordToHashedEntry).get(); - passwordStore.update(user.getId(), passwordByteIt); - return true; + try { + final HashEntry hashEntry = toHashEntry(credential); + passwordStore.update(user.getId(), hashEntry); + return true; + } + catch (IllegalArgumentException e) { + log.warn("Unable to update user '{}'", user.getId(), e); + } + return false; } diff --git a/backend/src/main/java/com/bakdata/conquery/models/auth/basic/PasswordHasher.java b/backend/src/main/java/com/bakdata/conquery/models/auth/basic/PasswordHasher.java index 8a12d1cd9f..68b1109acd 100644 --- a/backend/src/main/java/com/bakdata/conquery/models/auth/basic/PasswordHasher.java +++ b/backend/src/main/java/com/bakdata/conquery/models/auth/basic/PasswordHasher.java @@ -1,17 +1,5 @@ package com.bakdata.conquery.models.auth.basic; -import java.io.IOException; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.security.spec.InvalidKeySpecException; - -import javax.crypto.SecretKeyFactory; -import javax.crypto.spec.PBEKeySpec; - -import com.bakdata.conquery.io.jackson.Jackson; -import com.fasterxml.jackson.core.JsonProcessingException; -import jetbrains.exodus.ArrayByteIterable; -import jetbrains.exodus.ByteIterable; import lombok.Data; import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; @@ -25,63 +13,12 @@ @Slf4j public class PasswordHasher { - private static final SecureRandom RANDOM = new SecureRandom(); - private static final String ALGORITHM = "PBKDF2WithHmacSHA1"; - private static final int ITERATIONS = 10000; - private static final int KEY_LENGTH = 256; - - static { - log.info( - "Using the following settings to generate password hashes:\n\tAlgorithm: {}\n\tIterations: {}\n\tKey length: {}", - ALGORITHM, - ITERATIONS, - KEY_LENGTH); - } - - /** - * Returns a random salt to be used to hash a password. - * - * @return a 16 bytes random salt - */ - private static byte[] getNextSalt() { - byte[] salt = new byte[16]; - RANDOM.nextBytes(salt); - return salt; - } - - public static HashedEntry generateHashedEntry(char[] password) { - HashedEntry entry = new HashedEntry(); - entry.setSalt(getNextSalt()); - - entry.setHash(generateHash(password, entry.getSalt())); - return entry; - } - - public static byte[] generateHash(char[] password, byte[] salt) { - PBEKeySpec spec = new PBEKeySpec(password, salt, ITERATIONS, KEY_LENGTH); - SecretKeyFactory f = null; - try { - f = SecretKeyFactory.getInstance(ALGORITHM); - return f.generateSecret(spec).getEncoded(); - } - catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("The indicated algorithm was not found", e); - } - catch (InvalidKeySpecException e) { - throw new IllegalStateException("The key specification was invalid", e); - } - finally { - spec.clearPassword(); - } - } - @Data /** * Container class for the entries in the store consisting of the salted password hash and the corresponding salt. */ - public static class HashedEntry { - byte[] hash; - byte[] salt; + public static class HashEntry { + final String hash; } } diff --git a/backend/src/main/java/com/bakdata/conquery/models/auth/basic/PasswordHelper.java b/backend/src/main/java/com/bakdata/conquery/models/auth/basic/PasswordHelper.java new file mode 100644 index 0000000000..549c0ee70f --- /dev/null +++ b/backend/src/main/java/com/bakdata/conquery/models/auth/basic/PasswordHelper.java @@ -0,0 +1,40 @@ +package com.bakdata.conquery.models.auth.basic; + +import java.util.Map; +import java.util.function.Function; + +import com.password4j.Argon2Function; +import com.password4j.BcryptFunction; +import com.password4j.CompressedPBKDF2Function; +import com.password4j.HashingFunction; +import com.password4j.ScryptFunction; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; + +@UtilityClass +@Slf4j +public class PasswordHelper { + + private final static Map, Function> HASH_FUNCTION_GENERATORS = Map.of( + Argon2Function.class, Argon2Function::getInstanceFromHash, + ScryptFunction.class, ScryptFunction::getInstanceFromHash, + BcryptFunction.class, BcryptFunction::getInstanceFromHash, + CompressedPBKDF2Function.class, CompressedPBKDF2Function::getInstanceFromHash + ); + + /** + * Determines the function used to create the provided hash. + * The function can be used to hash a plain credential in order to check the hashes for equality. + */ + public HashingFunction getHashingFunction(String hash) { + for (Map.Entry, Function> hashFunctionGenerator : HASH_FUNCTION_GENERATORS.entrySet()) { + try { + return hashFunctionGenerator.getValue().apply(hash); + } + catch (Exception e) { + log.trace("Could not create hash function instance from hash using '{}'", hashFunctionGenerator.getKey()); + } + } + throw new IllegalArgumentException("No supported hash function recognized hash. Supported functions: " + HASH_FUNCTION_GENERATORS.keySet()); + } +} diff --git a/backend/src/main/java/com/bakdata/conquery/models/auth/basic/UserAuthenticationManagementProcessor.java b/backend/src/main/java/com/bakdata/conquery/models/auth/basic/UserAuthenticationManagementProcessor.java index 9723706fdf..a4724a36f9 100644 --- a/backend/src/main/java/com/bakdata/conquery/models/auth/basic/UserAuthenticationManagementProcessor.java +++ b/backend/src/main/java/com/bakdata/conquery/models/auth/basic/UserAuthenticationManagementProcessor.java @@ -27,7 +27,7 @@ public boolean tryRegister(ProtoUser pUser) { return false; } log.trace("Added the user {} to the authorization storage", id); - if(AuthorizationHelper.registerForAuthentication(realm, user, pUser.getCredentials(), false)) { + if (AuthorizationHelper.registerForAuthentication(realm, user, pUser.getCredential(), false)) { log.trace("Added the user {} to the realm {}", id, realm.getName()); return true; } @@ -37,7 +37,7 @@ public boolean tryRegister(ProtoUser pUser) { public boolean updateUser(ProtoUser pUser) { final User user = pUser.createOrOverwriteUser(storage); - AuthorizationHelper.registerForAuthentication(realm, user,pUser.getCredentials(),false); + AuthorizationHelper.registerForAuthentication(realm, user, pUser.getCredential(), false); return true; } diff --git a/backend/src/main/java/com/bakdata/conquery/models/auth/oidc/passwordflow/IdpDelegatingAccessTokenCreator.java b/backend/src/main/java/com/bakdata/conquery/models/auth/oidc/passwordflow/IdpDelegatingAccessTokenCreator.java index ede4952fde..d7242755b6 100644 --- a/backend/src/main/java/com/bakdata/conquery/models/auth/oidc/passwordflow/IdpDelegatingAccessTokenCreator.java +++ b/backend/src/main/java/com/bakdata/conquery/models/auth/oidc/passwordflow/IdpDelegatingAccessTokenCreator.java @@ -32,9 +32,9 @@ public class IdpDelegatingAccessTokenCreator implements AccessTokenCreator { @Override @SneakyThrows - public String createAccessToken(String username, char[] password) { - - Secret passwordSecret = new Secret(new String(password)); + public String createAccessToken(String username, String password) { + + Secret passwordSecret = new Secret(password); AuthorizationGrant grant = new ResourceOwnerPasswordCredentialsGrant(username, passwordSecret); diff --git a/backend/src/main/java/com/bakdata/conquery/models/config/auth/ApiTokenRealmFactory.java b/backend/src/main/java/com/bakdata/conquery/models/config/auth/ApiTokenRealmFactory.java deleted file mode 100644 index 7884bdb3b9..0000000000 --- a/backend/src/main/java/com/bakdata/conquery/models/config/auth/ApiTokenRealmFactory.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.bakdata.conquery.models.config.auth; - -import java.nio.file.Path; - -import javax.validation.constraints.NotNull; -import javax.ws.rs.container.ContainerRequestContext; -import javax.ws.rs.core.HttpHeaders; - -import com.bakdata.conquery.commands.ManagerNode; -import com.bakdata.conquery.io.cps.CPSType; -import com.bakdata.conquery.io.jackson.Jackson; -import com.bakdata.conquery.models.auth.ConqueryAuthenticationRealm; -import com.bakdata.conquery.models.auth.apitoken.ApiToken; -import com.bakdata.conquery.models.auth.apitoken.ApiTokenCreator; -import com.bakdata.conquery.models.auth.apitoken.ApiTokenRealm; -import com.bakdata.conquery.models.auth.apitoken.TokenStorage; -import com.bakdata.conquery.models.auth.web.DefaultAuthFilter; -import com.bakdata.conquery.models.config.XodusConfig; -import com.bakdata.conquery.resources.api.ApiTokenResource; -import io.dropwizard.jersey.setup.JerseyEnvironment; -import io.dropwizard.util.Strings; -import lombok.Data; -import org.apache.http.util.CharArrayBuffer; -import org.apache.shiro.authc.AuthenticationToken; -import org.checkerframework.checker.nullness.qual.Nullable; -import org.glassfish.hk2.utilities.binding.AbstractBinder; - -@CPSType(base = AuthenticationRealmFactory.class, id = "API_TOKEN") -@Data -public class ApiTokenRealmFactory implements AuthenticationRealmFactory { - - @NotNull - private final Path storeDir; - - @NotNull - private final XodusConfig apiTokenStoreConfig; - - @Override - public ConqueryAuthenticationRealm createRealm(ManagerNode managerNode) { - - final TokenStorage tokenStorage = new TokenStorage(storeDir, apiTokenStoreConfig, managerNode.getValidator(), Jackson.BINARY_MAPPER.copy()); - managerNode.getEnvironment().lifecycle().manage(tokenStorage); - - final ApiTokenRealm apiTokenRealm = new ApiTokenRealm(managerNode.getStorage(), tokenStorage); - - managerNode.getAuthController().getAuthenticationFilter().registerTokenExtractor(new ApiTokenExtractor()); - - JerseyEnvironment environment = managerNode.getEnvironment().jersey(); - - environment.register(new AbstractBinder() { - @Override - protected void configure() { - bind(apiTokenRealm).to(ApiTokenRealm.class); - } - }); - environment.register(ApiTokenResource.class); - - return apiTokenRealm; - } - - public static class ApiTokenExtractor implements DefaultAuthFilter.TokenExtractor { - - @Override - public @Nullable AuthenticationToken apply(@Nullable ContainerRequestContext input) { - if (input == null) { - return null; - } - - // Unfortunately there is no way around the String here - final String authHeader = input.getHeaderString(HttpHeaders.AUTHORIZATION); - - if (Strings.isNullOrEmpty(authHeader)) { - return null; - } - - String[] splits = authHeader.split(" "); - if (splits.length != 2) { - return null; - } - - String token = splits[1]; - - if (!token.startsWith(ApiTokenCreator.TOKEN_PREFIX)) { - return null; - } - - final CharArrayBuffer tokenBuf = new CharArrayBuffer(token.length()); - tokenBuf.append(token); - return new ApiToken(tokenBuf); - } - } - - -} diff --git a/backend/src/main/java/com/bakdata/conquery/models/config/auth/LocalAuthenticationConfig.java b/backend/src/main/java/com/bakdata/conquery/models/config/auth/LocalAuthenticationConfig.java index c39febe33f..fb3f005465 100644 --- a/backend/src/main/java/com/bakdata/conquery/models/config/auth/LocalAuthenticationConfig.java +++ b/backend/src/main/java/com/bakdata/conquery/models/config/auth/LocalAuthenticationConfig.java @@ -24,21 +24,27 @@ import com.bakdata.conquery.resources.admin.rest.UserAuthenticationManagementResource; import com.bakdata.conquery.resources.unprotected.LoginResource; import com.bakdata.conquery.resources.unprotected.TokenResource; +import com.password4j.BcryptFunction; +import com.password4j.BenchmarkResult; +import com.password4j.SystemChecker; import io.dropwizard.jersey.DropwizardResourceConfig; import io.dropwizard.util.Duration; import io.dropwizard.validation.MinDuration; import io.dropwizard.validation.ValidationMethod; import lombok.Getter; import lombok.Setter; +import lombok.extern.slf4j.Slf4j; @CPSType(base = AuthenticationRealmFactory.class, id = "LOCAL_AUTHENTICATION") @Getter @Setter +@Slf4j public class LocalAuthenticationConfig implements AuthenticationRealmFactory { public static final String REDIRECT_URI = "redirect_uri"; + public static final int BCRYPT_MAX_MILLISECONDS = 300; /** - * Configuration for the password store. An encryption for the store it self might be set here. + * Configuration for the password store. An encryption for the store itself might be set here. */ @NotNull private XodusConfig passwordStoreConfig = new XodusConfig(); @@ -74,6 +80,15 @@ public ConqueryAuthenticationRealm createRealm(ManagerNode manager) { // Token extractor is not needed because this realm depends on the ConqueryTokenRealm manager.getAuthController().getAuthenticationFilter().registerTokenExtractor(JWTokenHandler::extractToken); + log.info("Performing benchmark for default hash function (bcrypt) with max_milliseconds={}", BCRYPT_MAX_MILLISECONDS); + final BenchmarkResult result = SystemChecker.benchmarkBcrypt(BCRYPT_MAX_MILLISECONDS); + + final BcryptFunction prototype = result.getPrototype(); + int rounds = prototype.getLogarithmicRounds(); + long realElapsed = result.getElapsed(); + + + log.info("Using bcrypt with {} logarithmic rounds. Elapsed time={}", rounds, realElapsed); LocalAuthenticationRealm realm = new LocalAuthenticationRealm( manager.getValidator(), @@ -82,7 +97,9 @@ public ConqueryAuthenticationRealm createRealm(ManagerNode manager) { storeName, directory, passwordStoreConfig, - jwtDuration); + jwtDuration, + prototype + ); UserAuthenticationManagementProcessor processor = new UserAuthenticationManagementProcessor(realm, manager.getStorage()); // Register resources for users to exchange username and password for an access token diff --git a/backend/src/main/java/com/bakdata/conquery/resources/api/ApiTokenResource.java b/backend/src/main/java/com/bakdata/conquery/resources/api/ApiTokenResource.java deleted file mode 100644 index cbf5ed7ab0..0000000000 --- a/backend/src/main/java/com/bakdata/conquery/resources/api/ApiTokenResource.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.bakdata.conquery.resources.api; - -import com.bakdata.conquery.apiv1.auth.ApiTokenDataRepresentation; -import com.bakdata.conquery.io.jersey.ExtraMimeTypes; -import com.bakdata.conquery.models.auth.apitoken.ApiToken; -import com.bakdata.conquery.models.auth.apitoken.ApiTokenRealm; -import com.bakdata.conquery.models.auth.entities.User; -import com.bakdata.conquery.models.auth.entities.Subject; -import io.dropwizard.auth.Auth; -import lombok.RequiredArgsConstructor; - -import javax.inject.Inject; -import javax.validation.Valid; -import javax.ws.rs.*; -import javax.ws.rs.core.Response; - -import java.util.List; -import java.util.UUID; - -/** - * Endpoints to create and manage scoped {@link ApiToken}s. - */ -@Path("token") -@Consumes(ExtraMimeTypes.JSON_STRING) -@Produces(ExtraMimeTypes.JSON_STRING) -@RequiredArgsConstructor(onConstructor_ = {@Inject}) -public class ApiTokenResource { - - public static final String TOKEN = "token"; - private final ApiTokenRealm realm; - - - @POST - public ApiToken createToken(@Auth Subject subject, @Valid ApiTokenDataRepresentation.Request tokenData){ - - checkRealUser(subject); - - return realm.createApiToken(subject.getUser(), tokenData); - } - - @GET - public List listUserTokens(@Auth Subject subject) { - - checkRealUser(subject); - - return realm.listUserToken(subject); - } - - @DELETE - @Path("{" + TOKEN + "}") - public Response deleteToken(@Auth Subject subject, @PathParam(TOKEN) UUID id) { - - checkRealUser(subject); - - realm.deleteToken(subject, id); - - return Response.ok().build(); - } - - private static void checkRealUser(Subject subject) { - if (!(subject instanceof User)){ - throw new ForbiddenException("Only real users can request API-tokens"); - } - } -} diff --git a/backend/src/main/java/com/bakdata/conquery/resources/unprotected/TokenResource.java b/backend/src/main/java/com/bakdata/conquery/resources/unprotected/TokenResource.java index 23cd8c62d6..da4be49818 100644 --- a/backend/src/main/java/com/bakdata/conquery/resources/unprotected/TokenResource.java +++ b/backend/src/main/java/com/bakdata/conquery/resources/unprotected/TokenResource.java @@ -1,6 +1,7 @@ package com.bakdata.conquery.resources.unprotected; import javax.ws.rs.Consumes; +import javax.ws.rs.NotAuthorizedException; import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.Produces; @@ -10,9 +11,12 @@ import com.bakdata.conquery.apiv1.auth.UsernamePasswordToken; import com.bakdata.conquery.models.auth.basic.AccessTokenCreator; import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.shiro.authc.AuthenticationException; @Path("/") @AllArgsConstructor +@Slf4j public class TokenResource { private final AccessTokenCreator realm; @@ -21,6 +25,12 @@ public class TokenResource { @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public JwtWrapper getToken(UsernamePasswordToken token) { - return new JwtWrapper(realm.createAccessToken(token.getUser(), token.getPassword())); + try { + return new JwtWrapper(realm.createAccessToken(token.getUser(), token.getPassword())); + } + catch (AuthenticationException e) { + log.warn("Failed to authorize request", e); + throw new NotAuthorizedException("Failed to authenticate request. The cause has been logged."); + } } } diff --git a/backend/src/test/java/com/bakdata/conquery/integration/tests/ApiTokenRealmTest.java b/backend/src/test/java/com/bakdata/conquery/integration/tests/ApiTokenRealmTest.java deleted file mode 100644 index e5cf23acbc..0000000000 --- a/backend/src/test/java/com/bakdata/conquery/integration/tests/ApiTokenRealmTest.java +++ /dev/null @@ -1,229 +0,0 @@ -package com.bakdata.conquery.integration.tests; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import java.io.File; -import java.nio.file.Path; -import java.time.LocalDate; -import java.time.temporal.ChronoUnit; -import java.util.EnumSet; -import java.util.List; -import java.util.UUID; - -import javax.ws.rs.ClientErrorException; -import javax.ws.rs.NotAuthorizedException; -import javax.ws.rs.client.Entity; -import javax.ws.rs.core.GenericType; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; - -import com.bakdata.conquery.apiv1.IdLabel; -import com.bakdata.conquery.apiv1.auth.ApiTokenDataRepresentation; -import com.bakdata.conquery.integration.IntegrationTest; -import com.bakdata.conquery.io.storage.MetaStorage; -import com.bakdata.conquery.models.auth.apitoken.ApiToken; -import com.bakdata.conquery.models.auth.apitoken.ApiTokenRealm; -import com.bakdata.conquery.models.auth.apitoken.Scopes; -import com.bakdata.conquery.models.auth.conquerytoken.ConqueryTokenRealm; -import com.bakdata.conquery.models.auth.entities.User; -import com.bakdata.conquery.models.config.ConqueryConfig; -import com.bakdata.conquery.models.config.XodusConfig; -import com.bakdata.conquery.models.config.XodusStoreFactory; -import com.bakdata.conquery.models.config.auth.ApiTokenRealmFactory; -import com.bakdata.conquery.models.config.auth.AuthenticationRealmFactory; -import com.bakdata.conquery.models.identifiable.ids.specific.DatasetId; -import com.bakdata.conquery.resources.admin.rest.AdminDatasetsResource; -import com.bakdata.conquery.resources.api.ApiTokenResource; -import com.bakdata.conquery.resources.api.DatasetsResource; -import com.bakdata.conquery.resources.hierarchies.HierarchyHelper; -import com.bakdata.conquery.util.support.StandaloneSupport; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.MoreCollectors; - -public class ApiTokenRealmTest extends IntegrationTest.Simple implements ProgrammaticIntegrationTest { - - @Override - public ConqueryConfig overrideConfig(ConqueryConfig conf, final File workDir) { - - XodusStoreFactory storageConfig = (XodusStoreFactory) conf.getStorage(); - final Path storageDir = workDir.toPath().resolve(storageConfig.getDirectory().resolve(getClass().getSimpleName())); - - return conf.withStorage(storageConfig.withDirectory(storageDir)) - .withAuthenticationRealms(ImmutableList.builder() - .addAll(conf.getAuthenticationRealms()) - .add(new ApiTokenRealmFactory(storageDir, new XodusConfig())).build()) - ; - - } - - @Override - public void execute(StandaloneSupport conquery) throws Exception { - final User testUser = conquery.getTestUser(); - final ApiTokenRealm realm = conquery.getAuthorizationController().getAuthenticationRealms().stream() - .filter(ApiTokenRealm.class::isInstance) - .map(ApiTokenRealm.class::cast) - .collect(MoreCollectors.onlyElement()); - - final ConqueryTokenRealm conqueryTokenRealm = conquery.getAuthorizationController().getConqueryTokenRealm(); - final String userToken = conqueryTokenRealm.createTokenForUser(testUser.getId()); - - // Request ApiToken - final ApiTokenDataRepresentation.Request tokenRequest1 = new ApiTokenDataRepresentation.Request(); - - tokenRequest1.setName("test-token"); - tokenRequest1.setScopes(EnumSet.of(Scopes.DATASET)); - tokenRequest1.setExpirationDate(LocalDate.now().plus(1, ChronoUnit.DAYS)); - - ApiToken apiToken1 = requestApiToken(conquery, userToken, tokenRequest1); - - assertThat(apiToken1.getToken()).isNotBlank(); - - // List ApiToken - List apiTokens = - conquery.getClient() - .target(HierarchyHelper.hierarchicalPath(conquery.defaultApiURIBuilder(), ApiTokenResource.class,"listUserTokens")) - .request(MediaType.APPLICATION_JSON_TYPE) - .header("Authorization", "Bearer " + userToken) - .get(new GenericType>(){}); - - final ApiTokenDataRepresentation.Response expected = new ApiTokenDataRepresentation.Response(); - expected.setLastUsed(null); - expected.setCreationDate(LocalDate.now()); - expected.setExpirationDate(LocalDate.now().plus(1, ChronoUnit.DAYS)); - expected.setScopes(EnumSet.of(Scopes.DATASET)); - expected.setName("test-token"); - - assertThat(apiTokens).hasSize(1); - assertThat(apiTokens.get(0)).usingRecursiveComparison().ignoringFields("id").isEqualTo(expected); - - - // Request ApiToken 2 - final ApiTokenDataRepresentation.Request tokenRequest2 = new ApiTokenDataRepresentation.Request(); - - tokenRequest2.setName("test-token"); - tokenRequest2.setScopes(EnumSet.of(Scopes.ADMIN)); - tokenRequest2.setExpirationDate(LocalDate.now().plus(1, ChronoUnit.DAYS)); - - ApiToken apiToken2 = requestApiToken(conquery, userToken, tokenRequest2); - - assertThat(apiToken2.getToken()).isNotBlank(); - - // List ApiToken 2 - apiTokens = requestTokenList(conquery, userToken); - - assertThat(apiTokens).hasSize(2); - - // Use ApiToken1 to get Datasets - List> datasets = requestDatasets(conquery, apiToken1); - - assertThat(datasets).isNotEmpty(); - - // Use ApiToken2 to get Datasets - datasets = requestDatasets(conquery, apiToken2); - - assertThat(datasets).as("The second token has no scope for dataset").isEmpty(); - - - // Use ApiToken2 to access Admin - List adminDatasets = - conquery.getClient() - .target(HierarchyHelper.hierarchicalPath(conquery.defaultAdminURIBuilder(), AdminDatasetsResource.class,"listDatasets")) - .request(MediaType.APPLICATION_JSON_TYPE) - .header("Authorization", "Bearer " + apiToken2.getToken()) - .get(new GenericType<>() {}); - - assertThat(adminDatasets).as("The second token has scope for admin").isNotEmpty(); - - // Try to delete ApiToken2 with ApiToken (should fail) - final UUID id2 = apiTokens.stream().filter(t -> t.getScopes().contains(Scopes.ADMIN)).map(ApiTokenDataRepresentation.Response::getId).collect(MoreCollectors.onlyElement()); - Response response = - conquery.getClient() - .target(HierarchyHelper.hierarchicalPath(conquery.defaultApiURIBuilder(), ApiTokenResource.class,"deleteToken")) - .resolveTemplate(ApiTokenResource.TOKEN, id2) - .request(MediaType.APPLICATION_JSON_TYPE) - .header("Authorization", "Bearer " + apiToken2.getToken()) - .delete(Response.class); - - assertThat(response.getStatus()).as("It is forbidden to act on ApiTokens with ApiTokens").isEqualTo(403); - - - // Delete ApiToken2 with user token - response = - conquery.getClient() - .target(HierarchyHelper.hierarchicalPath(conquery.defaultApiURIBuilder(), ApiTokenResource.class,"deleteToken")) - .resolveTemplate(ApiTokenResource.TOKEN, id2) - .request(MediaType.APPLICATION_JSON_TYPE) - .header("Authorization", "Bearer " + userToken) - .delete(Response.class); - - assertThat(response.getStatus()).as("It is okay to act on ApiTokens with UserTokens").isEqualTo(200); - assertThat(realm.listUserToken(testUser)).hasSize(1); - - // Try to use the deleted token to access Admin - response = conquery.getClient() - .target(HierarchyHelper.hierarchicalPath(conquery.defaultAdminURIBuilder(), AdminDatasetsResource.class,"listDatasets")) - .request(MediaType.APPLICATION_JSON_TYPE) - .header("Authorization", "Bearer " + apiToken2.getToken()) - .get(Response.class); - - assertThat(response.getStatus()).as("Cannot use deleted token").isEqualTo(401); - - // Try to act on tokens from another user - final MetaStorage metaStorage = conquery.getMetaStorage(); - final User user2 = new User("TestUser2", "TestUser2", metaStorage); - metaStorage.addUser(user2); - final String user2Token = conqueryTokenRealm.createTokenForUser(user2.getId()); - - // Try to delete ApiToken2 with ApiToken (should fail) - final UUID id1 = apiTokens.stream().filter(t -> t.getScopes().contains(Scopes.DATASET)).map(ApiTokenDataRepresentation.Response::getId).collect(MoreCollectors.onlyElement()); - response = - conquery.getClient() - .target(HierarchyHelper.hierarchicalPath(conquery.defaultApiURIBuilder(), ApiTokenResource.class,"deleteToken")) - .resolveTemplate(ApiTokenResource.TOKEN, id1) - .request(MediaType.APPLICATION_JSON_TYPE) - .header("Authorization", "Bearer " + user2Token) - .delete(Response.class); - - assertThat(response.getStatus()).as("It is forbidden to act on someone else ApiTokens").isEqualTo(403); - - // Request ApiToken 3 (expired) - final ApiTokenDataRepresentation.Request tokenRequest3 = new ApiTokenDataRepresentation.Request(); - - tokenRequest3.setName("test-token"); - tokenRequest3.setScopes(EnumSet.of(Scopes.DATASET)); - tokenRequest3.setExpirationDate(LocalDate.now().minus(1, ChronoUnit.DAYS)); - - assertThatThrownBy(() -> requestApiToken(conquery, userToken, tokenRequest3)).as("Expiration date is in the past").isExactlyInstanceOf(ClientErrorException.class).hasMessageContaining("HTTP 422"); - - // Craft expired token behind validation to simulate the use of an expired token - ApiToken apiToken3 = realm.createApiToken(user2, tokenRequest3); - - assertThatThrownBy(() -> requestDatasets(conquery,apiToken3)).as("Expired token").isExactlyInstanceOf(NotAuthorizedException.class); - } - - private List> requestDatasets(StandaloneSupport conquery, ApiToken apiToken) { - return conquery.getClient() - .target(HierarchyHelper.hierarchicalPath(conquery.defaultApiURIBuilder(), DatasetsResource.class, "getDatasets")) - .request(MediaType.APPLICATION_JSON_TYPE) - .header("Authorization", "Bearer " + apiToken.getToken()) - .get(new GenericType<>() { - }); - } - - private List requestTokenList(StandaloneSupport conquery, String userToken) { - return conquery.getClient() - .target(HierarchyHelper.hierarchicalPath(conquery.defaultApiURIBuilder(), ApiTokenResource.class, "listUserTokens")) - .request(MediaType.APPLICATION_JSON_TYPE) - .header("Authorization", "Bearer " + userToken) - .get(new GenericType<>() {}); - } - - private ApiToken requestApiToken(StandaloneSupport conquery, String userToken, ApiTokenDataRepresentation.Request tokenRequest) { - return conquery.getClient() - .target(HierarchyHelper.hierarchicalPath(conquery.defaultApiURIBuilder(), ApiTokenResource.class, "createToken")) - .request(MediaType.APPLICATION_JSON_TYPE) - .header("Authorization", "Bearer " + userToken) - .post(Entity.entity(tokenRequest, MediaType.APPLICATION_JSON_TYPE), ApiToken.class); - } -} diff --git a/backend/src/test/java/com/bakdata/conquery/models/SerializationTests.java b/backend/src/test/java/com/bakdata/conquery/models/SerializationTests.java index 2a6b4f3f50..18e949e1b3 100644 --- a/backend/src/test/java/com/bakdata/conquery/models/SerializationTests.java +++ b/backend/src/test/java/com/bakdata/conquery/models/SerializationTests.java @@ -5,11 +5,9 @@ import java.io.IOException; import java.time.LocalDate; -import java.time.temporal.ChronoUnit; import java.util.Arrays; import java.util.BitSet; import java.util.Collections; -import java.util.EnumSet; import java.util.List; import java.util.Locale; import java.util.Map; @@ -36,9 +34,6 @@ import com.bakdata.conquery.io.jackson.Injectable; import com.bakdata.conquery.io.jackson.MutableInjectableValues; import com.bakdata.conquery.io.jackson.serializer.SerializationTestUtil; -import com.bakdata.conquery.models.auth.apitoken.ApiToken; -import com.bakdata.conquery.models.auth.apitoken.ApiTokenData; -import com.bakdata.conquery.models.auth.apitoken.Scopes; import com.bakdata.conquery.models.auth.entities.Group; import com.bakdata.conquery.models.auth.entities.Role; import com.bakdata.conquery.models.auth.entities.User; @@ -80,7 +75,6 @@ import com.bakdata.conquery.models.identifiable.ids.specific.DatasetId; import com.bakdata.conquery.models.identifiable.ids.specific.GroupId; import com.bakdata.conquery.models.identifiable.ids.specific.ManagedExecutionId; -import com.bakdata.conquery.models.identifiable.ids.specific.UserId; import com.bakdata.conquery.models.identifiable.mapping.EntityIdMap; import com.bakdata.conquery.models.query.ManagedQuery; import com.bakdata.conquery.models.query.entity.Entity; @@ -102,7 +96,6 @@ import io.dropwizard.jersey.validation.Validators; import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import org.apache.http.util.CharArrayBuffer; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; @@ -124,7 +117,7 @@ public void dataset() throws IOException, JSONException { @Test public void passwordCredential() throws IOException, JSONException { - PasswordCredential credential = new PasswordCredential("testPassword".toCharArray()); + PasswordCredential credential = new PasswordCredential("testPassword"); SerializationTestUtil .forType(PasswordCredential.class) @@ -565,31 +558,6 @@ public void testFormQuery() throws IOException, JSONException { .test(query); } - @Test - public void testApiTokenData() throws JSONException, IOException { - final CharArrayBuffer buffer = new CharArrayBuffer(5); - buffer.append("testtest"); - final ApiToken apiToken = new ApiToken(buffer); - final ApiTokenData - apiTokenData = - new ApiTokenData( - UUID.randomUUID(), - apiToken.hashToken(), - "tokenName", - new UserId("tokenUser"), - LocalDate.now(), - LocalDate.now().plus(1, ChronoUnit.DAYS), - EnumSet.of(Scopes.DATASET), - getMetaStorage() - ); - - - SerializationTestUtil - .forType(ApiTokenData.class) - .objectMappers(getManagerInternalMapper(), getApiMapper()) - .test(apiTokenData); - } - @Test void testMapDictionary() throws IOException, JSONException { diff --git a/backend/src/test/java/com/bakdata/conquery/models/auth/ApiTokenTest.java b/backend/src/test/java/com/bakdata/conquery/models/auth/ApiTokenTest.java deleted file mode 100644 index 5c923e1da5..0000000000 --- a/backend/src/test/java/com/bakdata/conquery/models/auth/ApiTokenTest.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.bakdata.conquery.models.auth; - -import static com.bakdata.conquery.models.auth.apitoken.ApiTokenCreator.*; -import static org.assertj.core.api.Assertions.*; - -import com.bakdata.conquery.models.auth.apitoken.ApiToken; -import com.bakdata.conquery.models.auth.apitoken.ApiTokenCreator; -import lombok.extern.slf4j.Slf4j; -import org.apache.http.util.CharArrayBuffer; -import org.junit.jupiter.api.Test; - -import java.util.Random; - -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.NotNull; - -@Slf4j -public class ApiTokenTest { - - @Test - public void checkToken () { - final ApiTokenCreator apiTokenCreator = new ApiTokenCreator(new Random(1)); - - final @NotNull @NotEmpty CharArrayBuffer buffer = apiTokenCreator.createToken().getToken(); - - log.info("Testing token: {}", buffer); - - assertThat(buffer).hasSize(TOKEN_LENGTH + TOKEN_PREFIX.length() + 1); - - assertThat(buffer).matches(TOKEN_PREFIX + "_" + "[\\w\\d_]{"+ TOKEN_LENGTH +"}"); - - assertThat(buffer.toString().substring(TOKEN_PREFIX.length()+2)).containsPattern("[a-zA-Z]"); - } -} diff --git a/backend/src/test/java/com/bakdata/conquery/models/auth/IdpDelegatingAccessTokenCreatorTest.java b/backend/src/test/java/com/bakdata/conquery/models/auth/IdpDelegatingAccessTokenCreatorTest.java index d6a3dfb1fc..0d30bd9e8a 100644 --- a/backend/src/test/java/com/bakdata/conquery/models/auth/IdpDelegatingAccessTokenCreatorTest.java +++ b/backend/src/test/java/com/bakdata/conquery/models/auth/IdpDelegatingAccessTokenCreatorTest.java @@ -98,7 +98,7 @@ private static void initOIDCServer() { @Test public void vaildUsernamePassword() { - String jwt = idpDelegatingAccessTokenCreator.createAccessToken(USER_1_NAME, USER_1_PASSWORD.toCharArray()); + String jwt = idpDelegatingAccessTokenCreator.createAccessToken(USER_1_NAME, USER_1_PASSWORD); assertThat(jwt).isEqualTo(USER_1_TOKEN); } @@ -107,7 +107,7 @@ public void vaildUsernamePassword() { public void invaildUsernamePassword() { log.info("This test will print an Error below."); assertThatThrownBy( - () -> idpDelegatingAccessTokenCreator.createAccessToken(USER_1_NAME, "bad_password".toCharArray())) + () -> idpDelegatingAccessTokenCreator.createAccessToken(USER_1_NAME, "bad_password")) .isInstanceOf(IllegalStateException.class); } diff --git a/backend/src/test/java/com/bakdata/conquery/models/auth/LocalAuthRealmTest.java b/backend/src/test/java/com/bakdata/conquery/models/auth/LocalAuthRealmTest.java index 3c2dd2d9fa..fa560e3c01 100644 --- a/backend/src/test/java/com/bakdata/conquery/models/auth/LocalAuthRealmTest.java +++ b/backend/src/test/java/com/bakdata/conquery/models/auth/LocalAuthRealmTest.java @@ -4,7 +4,6 @@ import java.io.File; import java.nio.file.Files; -import java.util.List; import com.auth0.jwt.JWT; import com.bakdata.conquery.apiv1.auth.PasswordCredential; @@ -14,13 +13,13 @@ import com.bakdata.conquery.models.auth.conquerytoken.ConqueryTokenRealm; import com.bakdata.conquery.models.auth.entities.User; import com.bakdata.conquery.models.config.XodusConfig; -import com.bakdata.conquery.models.identifiable.ids.specific.UserId; import com.bakdata.conquery.util.NonPersistentStoreFactory; +import com.password4j.BcryptFunction; import io.dropwizard.jersey.validation.Validators; import io.dropwizard.util.Duration; import org.apache.commons.io.FileUtils; -import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.BearerToken; +import org.apache.shiro.authc.CredentialsException; import org.apache.shiro.authc.IncorrectCredentialsException; import org.apache.shiro.util.LifecycleUtils; import org.junit.jupiter.api.AfterAll; @@ -53,7 +52,16 @@ public void setupAll() throws Exception { conqueryTokenRealm = new ConqueryTokenRealm(storage); - realm = new LocalAuthenticationRealm(Validators.newValidator(), Jackson.BINARY_MAPPER, conqueryTokenRealm, "localtestRealm", tmpDir, new XodusConfig(), Duration.hours(1)); + realm = + new LocalAuthenticationRealm( + Validators.newValidator(), + Jackson.BINARY_MAPPER, conqueryTokenRealm, + "localtestRealm", + tmpDir, + new XodusConfig(), + Duration.hours(4), + BcryptFunction.getInstance(4) + ); // 4 is minimum LifecycleUtils.init(realm); } @@ -61,9 +69,9 @@ public void setupAll() throws Exception { public void setupEach() { // Create User in Realm user1 = new User("TestUser", "Test User", storage); - PasswordCredential user1Password = new PasswordCredential("testPassword".toCharArray()); + PasswordCredential user1Password = new PasswordCredential("testPassword"); storage.addUser(user1); - realm.addUser(user1, List.of(user1Password)); + realm.addUser(user1, user1Password); } @AfterEach @@ -81,32 +89,32 @@ public void cleanUpAll() { @Test public void testEmptyUsername() { - assertThatThrownBy(() -> realm.createAccessToken("", "testPassword".toCharArray())) + assertThatThrownBy(() -> realm.createAccessToken("", "testPassword")) .isInstanceOf(IncorrectCredentialsException.class).hasMessageContaining("Username was empty"); } @Test public void testEmptyPassword() { - assertThatThrownBy(() -> realm.createAccessToken("TestUser", "".toCharArray())) + assertThatThrownBy(() -> realm.createAccessToken("TestUser", "")) .isInstanceOf(IncorrectCredentialsException.class).hasMessageContaining("Password was empty"); } @Test public void testWrongPassword() { - assertThatThrownBy(() -> realm.createAccessToken("TestUser", "wrongPassword".toCharArray())) - .isInstanceOf(AuthenticationException.class).hasMessageContaining("Provided username or password was not valid."); + assertThatThrownBy(() -> realm.createAccessToken("TestUser", "wrongPassword")) + .isInstanceOf(IncorrectCredentialsException.class).hasMessageContaining("Password was was invalid for user"); } @Test public void testWrongUsername() { - assertThatThrownBy(() -> realm.createAccessToken("NoTestUser", "testPassword".toCharArray())) - .isInstanceOf(AuthenticationException.class).hasMessageContaining("Provided username or password was not valid."); + assertThatThrownBy(() -> realm.createAccessToken("NoTestUser", "testPassword")) + .isInstanceOf(CredentialsException.class).hasMessageContaining("No password hash was found for user"); } @Test public void testValidUsernamePassword() { // Right username and password should yield a JWT - String jwt = realm.createAccessToken("TestUser", "testPassword".toCharArray()); + String jwt = realm.createAccessToken("TestUser", "testPassword"); assertThatCode(() -> JWT.decode(jwt)).doesNotThrowAnyException(); assertThat(conqueryTokenRealm.doGetAuthenticationInfo(new BearerToken(jwt)).getPrincipals().getPrimaryPrincipal()) @@ -116,13 +124,13 @@ public void testValidUsernamePassword() { @Test public void testUserUpdate() { - realm.updateUser(user1, List.of(new PasswordCredential("newTestPassword".toCharArray()))); + realm.updateUser(user1, new PasswordCredential("newTestPassword")); // Wrong (old) password - assertThatThrownBy(() -> realm.createAccessToken("TestUser", "testPassword".toCharArray())) - .isInstanceOf(AuthenticationException.class).hasMessageContaining("Provided username or password was not valid."); + assertThatThrownBy(() -> realm.createAccessToken("TestUser", "testPassword")) + .isInstanceOf(IncorrectCredentialsException.class).hasMessageContaining("Password was was invalid for user"); // Right (new) password - String jwt = realm.createAccessToken("TestUser", "newTestPassword".toCharArray()); + String jwt = realm.createAccessToken("TestUser", "newTestPassword"); assertThatCode(() -> JWT.decode(jwt)).doesNotThrowAnyException(); } @@ -130,8 +138,8 @@ public void testUserUpdate() { public void testRemoveUser() { realm.removeUser(user1); // Wrong password - assertThatThrownBy(() -> realm.createAccessToken("TestUser", "testPassword".toCharArray())) - .isInstanceOf(AuthenticationException.class).hasMessageContaining("Provided username or password was not valid."); + assertThatThrownBy(() -> realm.createAccessToken("TestUser", "testPassword")) + .isInstanceOf(CredentialsException.class).hasMessageContaining("No password hash was found for user"); } } diff --git a/backend/src/test/java/com/bakdata/conquery/util/PasswordHelperTest.java b/backend/src/test/java/com/bakdata/conquery/util/PasswordHelperTest.java new file mode 100644 index 0000000000..b6ae79e8c9 --- /dev/null +++ b/backend/src/test/java/com/bakdata/conquery/util/PasswordHelperTest.java @@ -0,0 +1,54 @@ +package com.bakdata.conquery.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.stream.Stream; + +import com.bakdata.conquery.models.auth.basic.PasswordHelper; +import com.password4j.Argon2Function; +import com.password4j.BcryptFunction; +import com.password4j.CompressedPBKDF2Function; +import com.password4j.HashingFunction; +import com.password4j.ScryptFunction; +import com.password4j.types.Argon2; +import com.password4j.types.Bcrypt; +import com.password4j.types.Hmac; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +public class PasswordHelperTest { + + + /** + * Arguments where generated with: + * + *
+	 * import com.password4j.Password;
+	 *
+	 * class Scratch {
+	 * 	public static void main(String[] args) {
+	 * 		System.out.println(Password.hash("test").withArgon2().getResult());
+	 * 		System.out.println(Password.hash("test").withScrypt().getResult());
+	 * 		System.out.println(Password.hash("test").withBcrypt().getResult());
+	 * 		System.out.println(Password.hash("test").withCompressedPBKDF2().getResult());
+	 *        }
+	 * }
+	 * 
+ */ + static Stream arguments() { + return Stream.of( + Arguments.arguments("$argon2id$v=19$m=15360,t=2,p=1$r35m/UGz8lq4ICjcNkb2GUcfYub07450QRTTapYwiJCQDOI9Maa0dlym/iL0AceTNNXgaxLUyGB5EfJoqr+Wng$WVnHZU8uwvufgWPlVh5T+MnTtX5Ry0hhCD0ej90L0Kk", Argon2Function.getInstance(15360, 2, 1, 32, Argon2.ID)), + Arguments.arguments("$100801$SBpPHCtLT+2FbJ2BS49J4sgRXfvduVm17U9yd0Ygky/3MgUgK1r4LMixKSQX4LQjSEuE6tV8ibABXXAr9tCZKA==$aPTssj2maVw34QgrhRIsUHu6irB1NrjiFpdpUXFHHA+XhjPG03PKrbj5CBXJx3cCUosU/IARQliSW2LWRLFtiw==", ScryptFunction.getInstance(65536, 8, 1, 64)), + Arguments.arguments("$2b$10$YMPj.MoAs81tO8HzrCYxnOujaPwbu5SGsSrdNyxdIJ9BlBIv9i0t.", BcryptFunction.getInstance(Bcrypt.B, 10)), + Arguments.arguments("$3$1331439861760256$+Rqke26gKhtP60UkVR2a3SfszrOkVrMiJ6LZUWvl2vI5OpW815zKiod8Sdz3aOcuajo6c1iKEXcWjk61emmgTw==$CiH0mwqibUZD5R5HqFNpaYCkWjiYcTQe0sjG+4ZYw/A=", CompressedPBKDF2Function.getInstance(Hmac.SHA256, 310000, 256)) + ); + } + + @ParameterizedTest + @MethodSource("arguments") + void test(String hash, HashingFunction hashProvider) { + assertThat(PasswordHelper.getHashingFunction(hash)).isEqualTo(hashProvider); + + } +}