blob: 0ea39ff1ca6d64807e651d1f9b848075327370df [file] [log] [blame]
/*
* 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 com.google.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.namepath.PathMapper;
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.
* <p>
* <h3>Backwards compatibility with Jackrabbit 2.x</h3>
* For security reasons the nodes storing the token information now have a
* dedicated node type (rep:Token) which has the following definition:
* <pre>
* [rep:Token] > mix:referenceable
* - rep:token.key (STRING) protected mandatory
* - rep:token.exp (DATE) protected mandatory
* - * (UNDEFINED) protected
* - * (UNDEFINED) multiple protected
* </pre>
* 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.<p>
* <p>
* 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 empty value.
*
* @param credentials The current credentials.
* @return {@code true} if the specified credentials or those extracted from
* {@link ImpersonationCredentials} are supported and 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 && attr.toString().isEmpty());
}
}
/**
* 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<String, ?> 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<String, ?> 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<String, ?> 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<String, String> mandatoryAttributes;
private final Map<String, String> 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<String,String> 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<String> attrNames = Arrays.asList(tokenCredentials.getAttributeNames());
publicAttributes.forEach((name, value) -> {
if (!attrNames.contains(name)) {
tokenCredentials.setAttribute(name, value);
}
});
return true;
}
@NotNull
@Override
public Map<String, String> getPrivateAttributes() {
return Collections.unmodifiableMap(mandatoryAttributes);
}
@NotNull
@Override
public Map<String, String> 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);
}
}
}