/* * Copyright (c) 2013-2017 Cinchapi Inc. * * Licensed 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 com.cinchapi.concourse.security; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.CharBuffer; import java.nio.channels.FileChannel; import java.security.SecureRandom; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.StampedLock; import org.joda.time.format.DateTimeFormatter; import org.joda.time.format.DateTimeFormatterBuilder; import static com.google.common.base.Preconditions.*; import com.cinchapi.concourse.Timestamp; import com.cinchapi.concourse.annotate.Restricted; import com.cinchapi.concourse.server.io.FileSystem; import com.cinchapi.concourse.thrift.AccessToken; import com.cinchapi.concourse.time.Time; import com.cinchapi.concourse.util.ByteBuffers; import com.cinchapi.concourse.util.Random; import com.cinchapi.concourse.util.Serializables; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import com.google.common.base.Strings; import com.google.common.base.Throwables; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import com.google.common.collect.HashBasedTable; import com.google.common.collect.Lists; import com.google.common.hash.Hashing; import com.google.common.primitives.Longs; /** * The {@link AccessManager} controls access to the server by keeping tracking * of valid credentials and handling authentication requests. * * @author Jeff Nelson */ public class AccessManager { /** * Create a new AccessManager that stores its credentials in * {@code backingStore}. * * @param backingStore * @return the AccessManager */ public static AccessManager create(String backingStore) { return new AccessManager(backingStore, ACCESS_TOKEN_TTL, ACCESS_TOKEN_TTL_UNIT); } /** * Create an AccessManager with the specified TTL for access tokens. This * method should only be used for testing. * * @param backingStore * @param accessTokenTtl * @param accessTokeTtlUnit * @return the AccessManager */ @Restricted protected static AccessManager createForTesting(String backingStore, int accessTokenTtl, TimeUnit accessTokeTtlUnit) { return new AccessManager(backingStore, accessTokenTtl, accessTokeTtlUnit); } /** * Return {@code true} if {@code username} is in valid format, * in which it must not be null or empty, or contain any whitespace. * * @param username * @return {@code true} if {@code username} is valid format */ @VisibleForTesting protected static boolean isAcceptableUsername(ByteBuffer username) { CharBuffer chars = ByteBuffers.toCharBuffer(username); boolean acceptable = chars.capacity() > 0; while (acceptable && chars.hasRemaining()) { char c = chars.get(); if(Character.isWhitespace(c)) { acceptable = false; break; } } return acceptable; } /** * Return {@code true} if {@code password} is in valid format, which means * it meets the following requirements: * <ul> * <li>{@value #MIN_PASSWORD_LENGTH} or more characters</li> * <li>At least one non whitespace character</li> * </ul> * * @param password * @return {@code true} if {@code password} is valid format */ @VisibleForTesting protected static boolean isSecurePassword(ByteBuffer password) { CharBuffer chars = ByteBuffers.toCharBuffer(password); if(password.capacity() >= MIN_PASSWORD_LENGTH) { while (chars.hasRemaining()) { char c = chars.get(); if(!Character.isWhitespace(c)) { return true; } } } return false; } /** * The number of hours for which an AccessToken is valid. */ private static final int ACCESS_TOKEN_TTL = 24; /** * The unit of time for which an AccessToken is valid. */ private static final TimeUnit ACCESS_TOKEN_TTL_UNIT = TimeUnit.HOURS; /** * The default admin password. If the AccessManager does not have any users, * it will automatically create an admin with this password. */ private static final String DEFAULT_ADMIN_PASSWORD = ByteBuffers .encodeAsHex(ByteBuffer.wrap("admin".getBytes())); /** * The default admin username. If the AccessManager does not have any users, * it will automatically create an admin with this username. */ private static final String DEFAULT_ADMIN_USERNAME = ByteBuffers .encodeAsHex(ByteBuffer.wrap("admin".getBytes())); /** * The column that contains a boolean which indicates if a user is enabled * or not {@link #credentials} table(When a user is created, its enabled by * default). */ private static final String ENABLED = "user_enabled"; /** * The minimum number of character that must be contained in a password. */ private static final int MIN_PASSWORD_LENGTH = 3; /** * The column that contains a user's password in the {@link #credentials} * table. */ private static final String PASSWORD_KEY = "password"; /** * The column that contains a user's salt in the {@link #credentials} table. */ private static final String SALT_KEY = "salt"; /** * A randomly chosen username for AccessToken's that act as service token's * for plugins and other non-user processes. The randomly generated name is * chosen so that it is impossible for it to conflict with an actual * username, based on the rules that govern valid usernames (e.g. usernames * cannot contain spaces) */ private static final String SERVICE_USERNAME = Random.getSimpleString() + " " + Random.getSimpleString(); /** * The column that contains a user's username in the {@link #credentials} * table. */ private static final String USERNAME_KEY = "username"; /** * The store where the credentials are serialized on disk. */ private final String backingStore; /** * A counter that assigns user ids. */ private AtomicInteger counter; /** * A table in memory that holds the user credentials. */ private final HashBasedTable<Short, String, Object> credentials; /** * Concurrency control. */ private final StampedLock lock = new StampedLock(); /** * Handles access tokens. */ private final AccessTokenManager tokenManager; /** * Construct a new instance. * * @param backingStore * @param accessTokenTtl * @param accessTokenTtlUnit */ @SuppressWarnings("unchecked") private AccessManager(String backingStore, int accessTokenTtl, TimeUnit accessTokenTtlUnit) { this.backingStore = backingStore; this.tokenManager = AccessTokenManager.create(accessTokenTtl, accessTokenTtlUnit); if(FileSystem.getFileSize(backingStore) > 0) { ByteBuffer bytes = FileSystem.readBytes(backingStore); credentials = Serializables.read(bytes, HashBasedTable.class); counter = new AtomicInteger( (int) Collections.max(credentials.rowKeySet())); } else { counter = new AtomicInteger(0); credentials = HashBasedTable.create(); // If there are no credentials (which implies this is a new server) // add the default admin username/password createUser(ByteBuffers.decodeFromHex(DEFAULT_ADMIN_USERNAME), ByteBuffers.decodeFromHex(DEFAULT_ADMIN_PASSWORD)); } } /** * Create access to the user identified by {@code username} with * {@code password}. * * <p> * If the existing user simply changes the password, the new auto-generated * id will not be generated and this username still has the same uid as the * time it has been assigned when this {@link AccessManager} is * instantiated. * </p> * * @param username * @param password */ public void createUser(ByteBuffer username, ByteBuffer password) { Preconditions.checkArgument(isAcceptableUsername(username), "Username must not be empty, or contain any whitespace."); Preconditions.checkArgument(isSecurePassword(password), "Password must not be empty, or have fewer than 3 characters."); long stamp = lock.writeLock(); try { ByteBuffer salt = Passwords.getSalt(); password = Passwords.hash(password, salt); boolean enabled = true; insert0(username, password, salt, enabled); tokenManager.deleteAllUserTokens(ByteBuffers.encodeAsHex(username)); diskSync(); } finally { lock.unlockWrite(stamp); } } /** * Delete access of the user identified by {@code username}. * * @param username */ public void deleteUser(ByteBuffer username) { long stamp = lock.writeLock(); try { String hex = ByteBuffers.encodeAsHex(username); checkArgument(!hex.equals(DEFAULT_ADMIN_USERNAME), "Cannot revoke access for the admin user!"); short uid = getUidByUsername0(username); credentials.remove(uid, USERNAME_KEY); credentials.remove(uid, PASSWORD_KEY); credentials.remove(uid, SALT_KEY); credentials.remove(uid, ENABLED); tokenManager.deleteAllUserTokens(hex); diskSync(); } finally { lock.unlockWrite(stamp); } } /** * Return a list of strings, each of which describes a currently existing * access token. * * @return a list of token descriptions */ public List<String> describeAllAccessTokens() { List<String> sessions = Lists.newArrayList(); List<AccessTokenWrapper> tokens = Lists .newArrayList(tokenManager.tokens.asMap().values()); Collections.sort(tokens); for (AccessTokenWrapper token : tokenManager.tokens.asMap().values()) { sessions.add(token.getDescription()); } return sessions; } /** * Check if the user exists and updates {@link #ENABLED} flag as false. * * @param username the username to disable */ public void disableUser(ByteBuffer username) { long stamp = lock.writeLock(); try { short uid = getUidByUsername0(username); String hex = ByteBuffers.encodeAsHex(username); credentials.put(uid, ENABLED, false); tokenManager.deleteAllUserTokens(hex); } finally { lock.unlockWrite(stamp); } } /** * Updates {@link #ENABLED} flag for the user as true in credentials table. * * @param username the username to enable */ public void enableUser(ByteBuffer username) { long stamp = lock.writeLock(); try { short uid = getUidByUsername0(username); credentials.put(uid, ENABLED, true); } finally { lock.unlockWrite(stamp); } } /** * Logout {@code token} so that it is not valid for subsequent access. * * @param token */ public void expireAccessToken(AccessToken token) { tokenManager.deleteToken(token); // the #tokenManager handles locking } /** * Remove the service {@code token} from the list of those that are valid. * <p> * <em>This is an alias for the {@link #expireAccessToken(AccessToken)} * method.</em> * </p> * * @param token the service token to remove */ public void expireServiceToken(AccessToken token) { expireAccessToken(token); } /** * Login {@code username} for subsequent access with the returned * {@link AccessToken}. * * @param username * @return the AccessToken */ public AccessToken getNewAccessToken(ByteBuffer username) { long stamp = lock.tryOptimisticRead(); checkArgument(isEnabledUsername(username)); if(!lock.validate(stamp)) { lock.readLock(); try { checkArgument(isEnabledUsername0(username)); } finally { lock.unlockRead(stamp); } } return tokenManager.addToken(ByteBuffers.encodeAsHex(username)); // tokenManager // handles // locking } /** * Return a new service token. * * <p> * A service token is an {@link AccessToken} that is not associated with an * actual user, but is instead generated based on the * {@link #SERVICE_USERNAME} and can be assigned to a non-user service or * process. * </p> * <p> * Service tokens do not expire! * </p> * * @return the new service token */ public AccessToken getNewServiceToken() { ByteBuffer bytes = ByteBuffers.fromString(SERVICE_USERNAME); return tokenManager.addToken(ByteBuffers.encodeAsHex(bytes)); } /** * Return the uid of the user associated with {@code token}. * * @param token * @return the uid */ public short getUidByAccessToken(AccessToken token) { long stamp = lock.tryOptimisticRead(); ByteBuffer username = getUsernameByAccessToken0(token); short uid = getUidByUsername0(username); if(!lock.validate(stamp)) { username = getUsernameByAccessToken0(token); uid = getUidByUsername0(username); } return uid; } /** * Return the uid of the user identified by {@code username}. * * @param username * @return the uid */ public short getUidByUsername(ByteBuffer username) { long stamp = lock.tryOptimisticRead(); short uid = getUidByUsername0(username); if(!lock.validate(stamp)) { stamp = lock.readLock(); try { uid = getUidByUsername0(username); } finally { lock.unlockRead(stamp); } } return uid; } /** * Return the binary format of username associated with {@code token}. * * @param token * @return the username */ public ByteBuffer getUsernameByAccessToken(AccessToken token) { long stamp = lock.tryOptimisticRead(); ByteBuffer username = getUsernameByAccessToken0(token); if(!lock.validate(stamp)) { stamp = lock.readLock(); try { username = getUsernameByAccessToken0(token); } finally { lock.unlock(stamp); } } return username; } /** * Return {@code true} if {@code username} exists and is enabled. * * @param username the username to check * @return {@code true} if {@code username} exists and is enabled */ public boolean isEnabledUsername(ByteBuffer username) { long stamp = lock.tryOptimisticRead(); boolean existing = isExistingUsername0(username); boolean enabled = isEnabledUsername0(username); if(!lock.validate(stamp)) { stamp = lock.readLock(); try { existing = isExistingUsername0(username); enabled = isEnabledUsername0(username); } finally { lock.unlockRead(stamp); } } return existing && enabled; } /** * Return {@code true} if {@code username} exists in {@link #backingStore}. * * @param username * @return {@code true} if {@code username} exists in {@link #backingStore} */ public boolean isExistingUsername(ByteBuffer username) { long stamp = lock.tryOptimisticRead(); boolean valid = isExistingUsername0(username); if(!lock.validate(stamp)) { stamp = lock.readLock(); try { valid = isExistingUsername0(username); } finally { lock.unlockRead(stamp); } } return valid; } /** * Return {@code true} if {@code username} and {@code password} is a valid * combination. * * @param username * @param password * @return {@code true} if {@code username}/{@code password} is valid */ public boolean isExistingUsernamePasswordCombo(ByteBuffer username, ByteBuffer password) { long stamp = lock.tryOptimisticRead(); boolean valid = isExistingUsernamePasswordCombo0(username, password); if(!lock.validate(stamp)) { stamp = lock.readLock(); try { valid = isExistingUsernamePasswordCombo0(username, password); } finally { lock.unlockRead(stamp); } } return valid; } /** * Return {@code true} if {@code token} is a valid AccessToken. * * @param token * @return {@code true} if {@code token} is valid */ public boolean isValidAccessToken(AccessToken token) { return tokenManager.isValidToken(token); // the #tokenManager does // locking } /** * Insert credential with {@code username}, {@code password}, * and {@code salt} into the memory store. * * @param username * @param password * @param salt */ protected void insert(ByteBuffer username, ByteBuffer password, ByteBuffer salt) { // visible for // upgrade task long stamp = lock.writeLock(); try { boolean enabled = true; insert0(username, password, salt, enabled); } finally { lock.unlockWrite(stamp); } } /** * Sync any changes made to the memory store to disk. */ private void diskSync() { // TODO take a backup in order to make this ACID durable... FileChannel channel = FileSystem.getFileChannel(backingStore); try { channel.position(0); Serializables.write(credentials, channel); } catch (IOException e) { throw Throwables.propagate(e); } finally { FileSystem.closeFileChannel(channel); } } /** * Implementation of {@link #getUidByAccessToken(AccessToken)}. * * @param username * @return the uid */ private short getUidByUsername0(ByteBuffer username) { checkArgument(isExistingUsername0(username)); Map<Short, Object> credsCol = credentials.column(USERNAME_KEY); for (Map.Entry<Short, Object> creds : credsCol.entrySet()) { String value = (String) creds.getValue(); if(value.equals(ByteBuffers.encodeAsHex(username))) { return creds.getKey(); } } return -1; // suppress compiler error // but this statement will // never actually execute } /** * Implementation of {@link #getUsernameByAccessToken(AccessToken)}. * * @param token * @return the username */ private ByteBuffer getUsernameByAccessToken0(AccessToken token) { String username = tokenManager.getUsernameByAccessToken(token); return ByteBuffers.decodeFromHex(username); } /** * Implementation of {@link #insert(ByteBuffer, ByteBuffer, ByteBuffer)} * without locking. * * @param username * @param password * @param salt */ private void insert0(ByteBuffer username, ByteBuffer password, ByteBuffer salt, boolean enabled) { short uid = isExistingUsername0(username) ? getUidByUsername0(username) : (short) counter.incrementAndGet(); credentials.put(uid, USERNAME_KEY, ByteBuffers.encodeAsHex(username)); credentials.put(uid, PASSWORD_KEY, ByteBuffers.encodeAsHex(password)); credentials.put(uid, SALT_KEY, ByteBuffers.encodeAsHex(salt)); credentials.put(uid, ENABLED, enabled); } /** * Implementation of {@link #isExistingUsername(ByteBuffer)}. * * @param username * @return {@code true} if {@code username} exiasts in {@link #backingStore} * . */ private boolean isEnabledUsername0(ByteBuffer username) { short uid = getUidByUsername0(username); Object enabled = credentials.get(uid, ENABLED); if(enabled == null) { enabled = true; credentials.put(uid, ENABLED, enabled); } return (boolean) enabled; } /** * Implementation of {@link #isExistingUsername(ByteBuffer)}. * * @param username * @return {@code true} if {@code username} exiasts in {@link #backingStore} * . */ private boolean isExistingUsername0(ByteBuffer username) { return credentials.containsValue(ByteBuffers.encodeAsHex(username)); } /** * Implementation of * {@link #isExistingUsernamePasswordCombo(ByteBuffer, ByteBuffer)}. * * @param username * @param password * @return {@code true} if {@code username}/{@code password} is valid */ private boolean isExistingUsernamePasswordCombo0(ByteBuffer username, ByteBuffer password) { if(isExistingUsername0(username)) { short uid = getUidByUsername0(username); ByteBuffer salt = ByteBuffers .decodeFromHex((String) credentials.get(uid, SALT_KEY)); password.rewind(); password = Passwords.hash(password, salt); return ByteBuffers.encodeAsHex(password) .equals((String) credentials.get(uid, PASSWORD_KEY)); } return false; } /** * The {@link AccessTokenManager} handles the work necessary to create, * validate and delete AccessTokens for the {@link AccessManager}. * * @author Jeff Nelson */ private final static class AccessTokenManager { // NOTE: This class does not define #hasCode() or #equals() because the // defaults are the desired behaviour. /** * Return a new {@link AccessTokenManager}. * * @param accessTokenTtl * @param accessTokenTtlUnit * @return the AccessTokenManager */ private static AccessTokenManager create(int accessTokenTtl, TimeUnit accessTokenTtlUnit) { return new AccessTokenManager(accessTokenTtl, accessTokenTtlUnit); } private final StampedLock lock = new StampedLock(); private final SecureRandom srand = new SecureRandom(); /** * The collection of currently valid tokens is maintained as a cache * mapping from a raw AccessToken to an AccessTokenWrapper. Each raw * AccessToken is unique and "equal" to its corresponding wrapper, which * contains metadata about the user and timestamp associated with the * access token. */ private final Cache<AccessToken, AccessTokenWrapper> tokens; /** * Construct a new instance. * * @param accessTokenTtl * @param accessTokenTtlUnit */ private AccessTokenManager(int accessTokenTtl, TimeUnit accessTokenTtlUnit) { this.tokens = CacheBuilder.newBuilder() .expireAfterWrite(accessTokenTtl, accessTokenTtlUnit) .build(); } /** * Add and return a new access token for {@code username}. * * @param username * @return the AccessToken */ public AccessToken addToken(String username) { long stamp = lock.writeLock(); try { long timestamp = Time.now(); StringBuilder sb = new StringBuilder(); sb.append(username); sb.append(srand.nextLong()); sb.append(timestamp); AccessToken token = new AccessToken(ByteBuffer.wrap(Hashing .sha256().hashUnencodedChars(sb.toString()).asBytes())); AccessTokenWrapper wapper = AccessTokenWrapper.create(token, username, timestamp); tokens.put(token, wapper); return token; } finally { lock.unlockWrite(stamp); } } /** * Invalidate any and all tokens that exist for {@code username}. * * @param username */ public void deleteAllUserTokens(String username) { long stamp = lock.writeLock(); try { for (AccessToken token : tokens.asMap().keySet()) { if(tokens.getIfPresent(token).getUsername() .equals(username)) { tokens.invalidate(token); } } } finally { lock.unlockWrite(stamp); } } /** * Invalidate {@code token} if it exists. * * @param token */ public void deleteToken(AccessToken token) { long stamp = lock.writeLock(); try { tokens.invalidate(token); } finally { lock.unlockWrite(stamp); } } /** * Return the username associated with the valid {@code token}. * * @param token * @return the username if {@code token} is valid */ public String getUsernameByAccessToken(AccessToken token) { Preconditions.checkArgument(isValidToken(token), "Access token is no longer invalid."); long stamp = lock.tryOptimisticRead(); String username = tokens.getIfPresent(token).getUsername(); if(!lock.validate(stamp)) { stamp = lock.readLock(); try { username = tokens.getIfPresent(token).getUsername(); } finally { lock.unlockRead(stamp); } } if(Strings.isNullOrEmpty(username)) { throw new IllegalArgumentException( "Access token is no longer valid"); } else { return username; } } /** * Return {@code true} if {@code token} is valid. * * @param token * @return {@code true} if {@code token} is valid */ public boolean isValidToken(AccessToken token) { long stamp = lock.tryOptimisticRead(); boolean valid = tokens.getIfPresent(token) != null; if(!lock.validate(stamp)) { stamp = lock.readLock(); try { valid = tokens.getIfPresent(token) != null; } finally { lock.unlockRead(stamp); } } return valid; } } /** * An {@link AccessTokenWrapper} associates metadata with an * {@link AccessToken}. This data isn't stored directly with the access * token because it would provide unnecessary bloat when the token is * transferred between client and server, so we use a wrapper on the server * side to assist with certain permissions based operations. * <p> * <strong>NOTE:</strong> The {@link #hashCode} and {@link #equals(Object)} * functions only take the wrapped token into account so that objects in * this class can be considered to the raw tokens they wrap for the purpose * of collection storage. * </p> * * @author Jeff Nelson */ private static class AccessTokenWrapper implements Comparable<AccessTokenWrapper> { /** * Create a new {@link AccessTokenWrapper} that wraps {@code token} for * {@code username} at {@code timestamp}. * * @param token * @param username * @param timestamp * @return the AccessTokenWrapper */ public static AccessTokenWrapper create(AccessToken token, String username, long timestamp) { return new AccessTokenWrapper(token, username, timestamp); } /** * The formatter that is used to when constructing a human readable * description of the access token. */ private static final DateTimeFormatter DATE_TIME_FORMATTER = new DateTimeFormatterBuilder() .appendMonthOfYearShortText().appendLiteral(" ") .appendDayOfMonth(1).appendLiteral(", ").appendYear(4, 4) .appendLiteral(" at ").appendHourOfDay(1).appendLiteral(":") .appendMinuteOfHour(2).appendLiteral(":") .appendSecondOfMinute(2).appendLiteral(" ") .appendHalfdayOfDayText().toFormatter(); private final long timestamp; private final AccessToken token; private final String username; // hex /** * Construct a new instance. * * @param token * @param username * @param timestamp */ private AccessTokenWrapper(AccessToken token, String username, long timestamp) { this.token = token; this.username = username; this.timestamp = timestamp; } @Override public int compareTo(AccessTokenWrapper o) { return Longs.compare(timestamp, o.timestamp); } @Override public boolean equals(Object obj) { if(obj instanceof AccessTokenWrapper) { return token.equals(((AccessTokenWrapper) obj).token); } return false; } /** * Return the wrapped access token. * * @return the token. */ @SuppressWarnings("unused") public AccessToken getAccessToken() { return token; } /** * Return a human readable description of the access token. * * @return the description */ public String getDescription() { String uname = ByteBuffers .getString(ByteBuffers.decodeFromHex(username)); uname = uname.equals(SERVICE_USERNAME) ? "BACKGROUND SERVICE" : uname; return uname + " logged in since " + Timestamp.fromMicros(timestamp) .getJoda().toString(DATE_TIME_FORMATTER); } /** * Return the timestamp that is associated with the wrapped access * token. * * @return the associated timestamp */ @SuppressWarnings("unused") public long getTimestamp() { return timestamp; } /** * Return the username that is represented by the wrapped access token. * * @return the associated username */ public String getUsername() { return username; } @Override public int hashCode() { return Objects.hash(token); } @Override public String toString() { return token.toString(); } } }