All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.apache.jackrabbit.oak.security.authentication.token.TokenProviderImpl Maven / Gradle / Ivy

There is a newer version: 1.66.0
Show newest version
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.jackrabbit.oak.security.authentication.token;

import java.io.UnsupportedEncodingException;
import java.security.NoSuchAlgorithmException;
import java.security.Principal;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import javax.jcr.AccessDeniedException;
import javax.jcr.Credentials;
import javax.jcr.RepositoryException;

import org.apache.jackrabbit.guava.common.collect.ImmutableMap;
import org.apache.jackrabbit.JcrConstants;
import org.apache.jackrabbit.api.security.authentication.token.TokenCredentials;
import org.apache.jackrabbit.api.security.user.Authorizable;
import org.apache.jackrabbit.api.security.user.User;
import org.apache.jackrabbit.api.security.user.UserManager;
import org.apache.jackrabbit.oak.api.CommitFailedException;
import org.apache.jackrabbit.oak.api.PropertyState;
import org.apache.jackrabbit.oak.api.Root;
import org.apache.jackrabbit.oak.api.Tree;
import org.apache.jackrabbit.oak.namepath.NamePathMapper;
import org.apache.jackrabbit.oak.plugins.identifier.IdentifierManager;
import org.apache.jackrabbit.oak.plugins.tree.TreeAware;
import org.apache.jackrabbit.oak.spi.namespace.NamespaceConstants;
import org.apache.jackrabbit.oak.spi.security.ConfigurationParameters;
import org.apache.jackrabbit.oak.spi.security.authentication.ImpersonationCredentials;
import org.apache.jackrabbit.oak.spi.security.authentication.credentials.CredentialsSupport;
import org.apache.jackrabbit.oak.spi.security.authentication.credentials.SimpleCredentialsSupport;
import org.apache.jackrabbit.oak.spi.security.authentication.token.TokenConstants;
import org.apache.jackrabbit.oak.spi.security.authentication.token.TokenInfo;
import org.apache.jackrabbit.oak.spi.security.authentication.token.TokenProvider;
import org.apache.jackrabbit.oak.spi.security.user.UserConfiguration;
import org.apache.jackrabbit.oak.spi.security.user.util.PasswordUtil;
import org.apache.jackrabbit.oak.plugins.tree.TreeUtil;
import org.apache.jackrabbit.util.ISO8601;
import org.apache.jackrabbit.util.Text;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static org.apache.jackrabbit.oak.api.Type.DATE;
import static org.apache.jackrabbit.oak.api.Type.STRING;
import static org.apache.jackrabbit.oak.plugins.identifier.IdentifierManager.getIdentifier;

/**
 * Default implementation of the {@code TokenProvider} interface that keeps login
 * tokens in the content repository. As a precondition the configured the user
 * management implementation must provide paths for all
 * {@link org.apache.jackrabbit.api.security.user.User users} that refer to
 * a valid {@link Tree} in the content repository.
 * 

*

Backwards compatibility with Jackrabbit 2.x

* For security reasons the nodes storing the token information now have a * dedicated node type (rep:Token) which has the following definition: *
 *     [rep:Token] > mix:referenceable
 *      - rep:token.key (STRING) protected mandatory
 *      - rep:token.exp (DATE) protected mandatory
 *      - * (UNDEFINED) protected
 *      - * (UNDEFINED) multiple protected
 * 
* Consequently the hash of the token and the expiration time of tokens generated * by this provider can no longer be manipulated using regular JCR item * modifications.

*

* Existing login tokens generated by Jackrabbit 2.x which are migrated to * OAK will still be valid (unless they expire) due to the fact that * {@link #getTokenInfo(String)} and the implementation of the {@link TokenInfo} * interface will not validate the node type of the token node associated with * a given token. */ class TokenProviderImpl implements TokenProvider, TokenConstants { private static final Logger log = LoggerFactory.getLogger(TokenProviderImpl.class); /** * Optional configuration parameter to define the number of token nodes that * when exceeded will trigger a cleanup of expired tokens upon creation. */ static final String PARAM_TOKEN_CLEANUP_THRESHOLD = "tokenCleanupThreshold"; /** * Default value indicating that tokens should never be cleaned up (i.e. * backwards compatible behavior). */ static final long NO_TOKEN_CLEANUP = 0; /** * Default expiration time in ms for login tokens is 2 hours. */ static final long DEFAULT_TOKEN_EXPIRATION = 2 * 3600 * 1000; static final int DEFAULT_KEY_SIZE = 8; private static final char DELIM = '_'; private final Root root; private final ConfigurationParameters options; private final CredentialsSupport credentialsSupport; private final long tokenExpiration; private final UserManager userManager; private final IdentifierManager identifierManager; private final long cleanupThreshold; TokenProviderImpl(@NotNull Root root, @NotNull ConfigurationParameters options, @NotNull UserConfiguration userConfiguration) { this(root, options, userConfiguration, SimpleCredentialsSupport.getInstance()); } TokenProviderImpl(@NotNull Root root, @NotNull ConfigurationParameters options, @NotNull UserConfiguration userConfiguration, @NotNull CredentialsSupport credentialsSupport) { this.root = root; this.options = options; this.credentialsSupport = credentialsSupport; this.tokenExpiration = options.getConfigValue(PARAM_TOKEN_EXPIRATION, DEFAULT_TOKEN_EXPIRATION); this.userManager = userConfiguration.getUserManager(root, NamePathMapper.DEFAULT); this.identifierManager = new IdentifierManager(root); this.cleanupThreshold = options.getConfigValue(PARAM_TOKEN_CLEANUP_THRESHOLD, NO_TOKEN_CLEANUP); } //------------------------------------------------------< TokenProvider >--- /** * Returns {@code true} if {@code SimpleCredentials} can be extracted from * the specified credentials object and that simple credentials object has * a {@link #TOKEN_ATTRIBUTE} attribute with an {@link #TOKEN_ATTRIBUTE_DO_CREATE empty value}. * * @param credentials The current credentials. * @return {@code true} if the specified credentials or those extracted from * {@link ImpersonationCredentials} are supported and if the (extracted) * credentials object contain a {@link #TOKEN_ATTRIBUTE} attribute with an * empty value; {@code false} otherwise. */ @Override public boolean doCreateToken(@NotNull Credentials credentials) { Credentials creds = extractCredentials(credentials); if (creds == null) { return false; } else { Object attr = credentialsSupport.getAttributes(creds).get(TOKEN_ATTRIBUTE); return (attr != null && TOKEN_ATTRIBUTE_DO_CREATE.equals(attr.toString())); } } /** * Create a separate token node underneath a dedicated token store within * the user home node. That token node contains the hashed token, the * expiration time and additional mandatory attributes that will be verified * during login. * * @param credentials The current credentials. * @return A new {@code TokenInfo} or {@code null} if the token could not * be created. */ @Nullable @Override public TokenInfo createToken(@NotNull Credentials credentials) { Credentials creds = extractCredentials(credentials); String uid = (creds != null) ? credentialsSupport.getUserId(creds) : null; TokenInfo tokenInfo = null; if (uid != null) { Map attributes = credentialsSupport.getAttributes(creds); tokenInfo = createToken(uid, attributes); if (tokenInfo != null) { // also set the new token to the credentials. if (!credentialsSupport.setAttributes(creds, ImmutableMap.of(TOKEN_ATTRIBUTE, tokenInfo.getToken()))) { log.debug("Cannot set token attribute to {}", creds); } } } return tokenInfo; } /** * Create a separate token node underneath a dedicated token store within * the user home node. That token node contains the hashed token, the * expiration time and additional mandatory attributes that will be verified * during login. * * @param userId The identifier of the user for which a new token should * be created. * @param attributes The attributes associated with the new token. * @return A new {@code TokenInfo} or {@code null} if the token could not * be created. */ @Nullable @Override public TokenInfo createToken(@NotNull String userId, @NotNull Map attributes) { String error = "Failed to create login token. {}"; User user = getUser(userId); Tree tokenParent = (user == null) ? null : getTokenParent(user); if (tokenParent != null) { try { String id = user.getID(); long creationTime = System.currentTimeMillis(); long exp; if (attributes.containsKey(PARAM_TOKEN_EXPIRATION)) { exp = Long.parseLong(attributes.get(PARAM_TOKEN_EXPIRATION).toString()); } else { exp = tokenExpiration; } long expTime = createExpirationTime(creationTime, exp); String uuid = UUID.randomUUID().toString(); TokenInfo tokenInfo; try { String tokenName = uuid; tokenInfo = createTokenNode(tokenParent, tokenName, expTime, uuid, id, attributes); root.commit(CommitMarker.asCommitAttributes()); } catch (CommitFailedException e) { // conflict while creating token node -> retry log.debug("Failed to create token node. Using random name as fallback."); root.refresh(); tokenInfo = createTokenNode(tokenParent, UUID.randomUUID().toString(), expTime, uuid, id, attributes); root.commit(CommitMarker.asCommitAttributes()); } cleanupExpired(userId, tokenParent, creationTime, tokenInfo.getToken()); return tokenInfo; } catch (NoSuchAlgorithmException | UnsupportedEncodingException | CommitFailedException | RepositoryException e) { // error while generating login token or while committing changes log.error(error, e.getMessage()); } } else { log.error("Unable to get/create token store for user {}.", userId); } return null; } /** * Retrieves the token information associated with the specified login * token. If no accessible {@code Tree} exists for the given token or if * the token is not associated with a valid user this method returns {@code null}. * * @param token A valid login token. * @return The {@code TokenInfo} associated with the specified token or * {@code null} of the corresponding information does not exist or is not * associated with a valid user. */ @Nullable @Override public TokenInfo getTokenInfo(@NotNull String token) { int pos = token.indexOf(DELIM); String nodeId = (pos == -1) ? token : token.substring(0, pos); Tree tokenTree = identifierManager.getTree(nodeId); if (isValidTokenTree(tokenTree)) { try { User user = getUser(tokenTree); if (user != null) { return new TokenInfoImpl(tokenTree, token, user.getID(), user.getPrincipal()); } } catch (RepositoryException e) { log.debug("Cannot determine userID/principal from token: {}", e.getMessage()); } } // invalid token tree or failed to extract user or it's id/principal return null; } //-------------------------------------------------------------------------- private static long createExpirationTime(long creationTime, long tokenExpiration) { return creationTime + tokenExpiration; } private static long getExpirationTime(@NotNull Tree tokenTree, long defaultValue) { return TreeUtil.getLong(tokenTree, TOKEN_ATTRIBUTE_EXPIRY, defaultValue); } private static boolean isExpired(long expirationTime, long loginTime) { return expirationTime < loginTime; } private static void setExpirationTime(@NotNull Tree tree, long time) { Calendar calendar = Calendar.getInstance(); calendar.setTimeInMillis(time); tree.setProperty(TOKEN_ATTRIBUTE_EXPIRY, ISO8601.format(calendar), DATE); } @Nullable private Credentials extractCredentials(@NotNull Credentials credentials) { Credentials creds = credentials; if (credentials instanceof ImpersonationCredentials) { creds = ((ImpersonationCredentials) credentials).getBaseCredentials(); } if (credentialsSupport.getCredentialClasses().contains(creds.getClass())) { return creds; } else { return null; } } @NotNull private static String generateKey(int size) { SecureRandom random = new SecureRandom(); byte key[] = new byte[size]; random.nextBytes(key); StringBuilder res = new StringBuilder(key.length * 2); for (byte b : key) { res.append(Text.hexTable[(b >> 4) & 15]); res.append(Text.hexTable[b & 15]); } return res.toString(); } @NotNull private static String getKeyValue(@NotNull String key, @NotNull String userId) { return key + userId; } private static boolean isValidTokenTree(@Nullable Tree tokenTree) { if (tokenTree == null || !tokenTree.exists()) { return false; } else { return TOKENS_NODE_NAME.equals(tokenTree.getParent().getName()) && TOKEN_NT_NAME.equals(TreeUtil.getPrimaryTypeName(tokenTree)); } } @NotNull private Tree getTokenTree(@NotNull TokenInfoImpl tokenInfo) { return root.getTree(tokenInfo.tokenPath); } @Nullable private User getUser(@NotNull Tree tokenTree) throws RepositoryException { String userPath = Text.getRelativeParent(tokenTree.getPath(), 2); Authorizable authorizable = userManager.getAuthorizableByPath(userPath); if (authorizable != null && !authorizable.isGroup() && !((User) authorizable).isDisabled()) { return (User) authorizable; } else { return null; } } @Nullable private User getUser(@NotNull String userId) { try { Authorizable user = userManager.getAuthorizable(userId); if (user != null && !user.isGroup()) { return (User) user; } else { log.debug("Cannot create login token: No corresponding node for User {}.", userId); } } catch (RepositoryException e) { // error while accessing user. log.debug("Error while accessing user " + userId + '.', e); } return null; } @Nullable private Tree getTokenParent(@NotNull User user) { Tree tokenParent = null; String parentPath = null; try { String userPath = user.getPath(); parentPath = userPath + '/' + TOKENS_NODE_NAME; Tree userNode = getTree(user, userPath); tokenParent = TreeUtil.getOrAddChild(userNode, TOKENS_NODE_NAME, TOKENS_NT_NAME); root.commit(); } catch (RepositoryException e) { // error while creating token node. log.debug("Error while creating token node {}", e.getMessage()); } catch (CommitFailedException e) { // conflict while creating token store for this user -> refresh and // try to get the tree from the updated root. log.debug("Conflict while creating token store -> retrying {}", e.getMessage()); root.refresh(); Tree parentTree = root.getTree(parentPath); if (parentTree.exists()) { tokenParent = parentTree; } else { tokenParent = null; } } return tokenParent; } public @NotNull Tree getTree(@NotNull User user, @NotNull String userPath) { if (user instanceof TreeAware) { return ((TreeAware) user).getTree(); } else { return root.getTree(userPath); } } /** * Create a new token node below the specified {@code parent}. * * @param parent The parent node. * @param expTime The expiration time of the new token. * @param uuid The uuid of the token node. * @param id The id of the user that issues the token. * @param attributes The additional attributes of the token to be created. * @return The new token info * @throws AccessDeniedException If the editing session cannot access the * new token node. * */ @NotNull private TokenInfo createTokenNode(@NotNull Tree parent, @NotNull String tokenName, long expTime, @NotNull String uuid, @NotNull String id, Map attributes) throws AccessDeniedException, UnsupportedEncodingException, NoSuchAlgorithmException { Tree tokenNode = TreeUtil.addChild(parent, tokenName, TOKEN_NT_NAME); tokenNode.setProperty(JcrConstants.JCR_UUID, uuid); String key = generateKey(options.getConfigValue(PARAM_TOKEN_LENGTH, DEFAULT_KEY_SIZE)); String nodeId = getIdentifier(tokenNode); String token = nodeId + DELIM + key; String keyHash = PasswordUtil.buildPasswordHash(getKeyValue(key, id), options); tokenNode.setProperty(TOKEN_ATTRIBUTE_KEY, keyHash); setExpirationTime(tokenNode, expTime); attributes.forEach((name, value) -> { if (!RESERVED_ATTRIBUTES.contains(name)) { tokenNode.setProperty(name, value.toString()); } }); return new TokenInfoImpl(tokenNode, token, id, null); } /** * Remove expired token nodes if the configured threshold (i.e. number of * token nodes) is matched/exceeded. By default (i.e. unless configured with * a value bigger than {@link #NO_TOKEN_CLEANUP}) no cleanup is performed * and this method returns without looking at the token nodes; this makes * this addition optional and will not affect existing configurations. * * @param parent * The token parent node. * @param currentTime * The time to be used for analysing expiration of existing * tokens. * @param token * The token info used as random data to skip cleanup. */ private void cleanupExpired(@NotNull String userId, @NotNull Tree parent, long currentTime, @NotNull String token) { if (cleanupThreshold > NO_TOKEN_CLEANUP && shouldRunCleanup(token)) { long start = System.currentTimeMillis(); long active = 0; long expired = 0; try { if (parent.getChildrenCount(cleanupThreshold) >= cleanupThreshold) { for (Tree child : parent.getChildren()) { if (isExpired(getExpirationTime(child, Long.MIN_VALUE), currentTime)) { expired++; child.remove(); } else { active++; } } } if (root.hasPendingChanges()) { root.commit(CommitMarker.asCommitAttributes()); } } catch (CommitFailedException e) { log.debug("Failed to cleanup expired token nodes", e); root.refresh(); } finally { if (active + expired > 0) { log.debug("Token cleanup completed in {} ms: removed {}/{} tokens for {}.", System.currentTimeMillis() - start, expired, active + expired, userId); } } } } /** * Method that determines if the cleanup should run or not based on the * randomly generated token's first char, this decreases the chances to 1/8. * * @param token The target token * @return true if the cleanup should run */ static boolean shouldRunCleanup(@NotNull String token) { return token.charAt(0) < '2'; } // -------------------------------------------------------------------------- /** * TokenInfo */ final class TokenInfoImpl implements TokenInfo { private final String token; private final String tokenPath; private final String userId; private final Principal principal; private final long expirationTime; private final String key; private final Map mandatoryAttributes; private final Map publicAttributes; private TokenInfoImpl(@NotNull Tree tokenTree, @NotNull String token, @NotNull String userId, @Nullable Principal principal) { this.token = token; this.tokenPath = tokenTree.getPath(); this.userId = userId; this.principal = principal; expirationTime = getExpirationTime(tokenTree, Long.MIN_VALUE); key = TreeUtil.getString(tokenTree, TOKEN_ATTRIBUTE_KEY); mandatoryAttributes = new HashMap<>(); publicAttributes = new HashMap<>(); for (PropertyState propertyState : tokenTree.getProperties()) { String name = propertyState.getName(); String value = propertyState.getValue(STRING); if (RESERVED_ATTRIBUTES.contains(name)) { continue; } if (isMandatoryAttribute(name)) { mandatoryAttributes.put(name, value); } else if (isInfoAttribute(name)) { // info attribute publicAttributes.put(name, value); } // else: jcr specific property } } @Nullable Principal getPrincipal() { return principal; } //------------------------------------------------------< TokenInfo >--- @NotNull @Override public String getUserId() { return userId; } @NotNull @Override public String getToken() { return token; } @Override public boolean isExpired(long loginTime) { return TokenProviderImpl.isExpired(expirationTime, loginTime); } @Override public boolean resetExpiration(long loginTime) { // for backwards compatibility use true as default value for the 'tokenRefresh' configuration if (options.getConfigValue(PARAM_TOKEN_REFRESH, true)) { Tree tokenTree = getTokenTree(this); if (tokenTree.exists()) { if (isExpired(loginTime)) { log.debug("Attempt to reset an expired token."); return false; } if (expirationTime - loginTime <= tokenExpiration / 2) { try { long expTime = createExpirationTime(loginTime, tokenExpiration); setExpirationTime(tokenTree, expTime); root.commit(CommitMarker.asCommitAttributes()); log.debug("Successfully reset token expiration time."); return true; } catch (CommitFailedException e) { log.debug("Failed to reset token expiration {}", e.getMessage()); root.refresh(); } } } } return false; } @Override public boolean remove() { Tree tokenTree = getTokenTree(this); if (tokenTree.exists()) { try { if (tokenTree.remove()) { root.commit(CommitMarker.asCommitAttributes()); return true; } } catch (CommitFailedException e) { log.debug("Error while removing expired token {}", e.getMessage()); } } return false; } @Override public boolean matches(@NotNull TokenCredentials tokenCredentials) { String tk = tokenCredentials.getToken(); int pos = tk.lastIndexOf(DELIM); if (pos > -1) { tk = tk.substring(pos + 1); } if (!PasswordUtil.isSame(key, getKeyValue(tk, userId))) { return false; } for (Map.Entry mandatory : mandatoryAttributes.entrySet()) { String name = mandatory.getKey(); String expectedValue = mandatory.getValue(); if (!expectedValue.equals(tokenCredentials.getAttribute(name))) { return false; } } // update set of informative attributes on the credentials // based on the properties present on the token node. Collection attrNames = Arrays.asList(tokenCredentials.getAttributeNames()); publicAttributes.forEach((name, value) -> { if (!attrNames.contains(name)) { tokenCredentials.setAttribute(name, value); } }); return true; } @NotNull @Override public Map getPrivateAttributes() { return Collections.unmodifiableMap(mandatoryAttributes); } @NotNull @Override public Map getPublicAttributes() { return Collections.unmodifiableMap(publicAttributes); } /** * Returns {@code true} if the specified {@code attributeName} * starts with or equals {@link #TOKEN_ATTRIBUTE}. * * @param attributeName The attribute name. * @return {@code true} if the specified {@code attributeName} * starts with or equals {@link #TOKEN_ATTRIBUTE}. */ private boolean isMandatoryAttribute(@NotNull String attributeName) { return attributeName.startsWith(TOKEN_ATTRIBUTE); } /** * Returns {@code false} if the specified attribute name doesn't have * a 'jcr' or 'rep' namespace prefix; {@code true} otherwise. This is * a lazy evaluation in order to avoid testing the defining node type of * the associated jcr property. * * @param attributeName The attribute name. * @return {@code true} if the specified property name doesn't seem * to represent repository internal information. */ private boolean isInfoAttribute(@NotNull String attributeName) { String prefix = Text.getNamespacePrefix(attributeName); return !NamespaceConstants.RESERVED_PREFIXES.contains(prefix); } } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy