/*
* The MIT License (MIT)
*
* Copyright (c) 2016 Jakob Hendeß
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package org.xlrnet.metadict.web.auth.services;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xlrnet.metadict.api.auth.Role;
import org.xlrnet.metadict.api.auth.User;
import org.xlrnet.metadict.api.exception.MetadictRuntimeException;
import org.xlrnet.metadict.api.storage.StorageBackendException;
import org.xlrnet.metadict.api.storage.StorageOperationException;
import org.xlrnet.metadict.api.storage.StorageService;
import org.xlrnet.metadict.core.services.storage.DefaultStorageService;
import org.xlrnet.metadict.web.auth.entities.BasicAuthData;
import org.xlrnet.metadict.web.auth.entities.UserFactory;
import org.xlrnet.metadict.web.middleware.util.CryptoUtils;
import javax.inject.Inject;
import javax.xml.bind.DatatypeConverter;
import java.util.Arrays;
import java.util.Optional;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Function;
import static com.google.common.base.Preconditions.checkNotNull;
/**
* Central service for user management. This includes creating new users and authenticating them.
*/
public class UserService {
/**
* Name of the namespace which contains basic authentication data.
*/
static final String BASIC_AUTH_NAMESPACE = "AUTH_BASIC";
/**
* General user namespace.
*/
static final String GENERAL_USER_NAMESPACE = "USERS";
/**
* Default byte size of technical users should be 16. This will create hexadecimal user names with 32 characters.
*/
private static final int TECHNICAL_USER_NAME_LENGTH = 16;
private static final Logger LOGGER = LoggerFactory.getLogger(UserService.class);
private static ReentrantLock createUserLock = new ReentrantLock();
private final StorageService storageService;
private final UserFactory userFactory;
@Inject
public UserService(@DefaultStorageService StorageService storageService, UserFactory userFactory) {
this.storageService = storageService;
this.userFactory = userFactory;
}
@NotNull
public Optional<User> createNewUser(@NotNull String username, @NotNull String unhashedPassword, Role... additionalRoles) {
User user = internalCreateNewUser(username, unhashedPassword, (String u) -> this.userFactory.newDefaultUser(u, additionalRoles));
return Optional.ofNullable(user);
}
@Nullable
private User internalCreateNewUser(@NotNull String username, @NotNull String unhashedPassword, Function<String, User> userSupplier) {
checkNotNull(username, "Username may not be null");
checkNotNull(unhashedPassword, "Password may not be null");
User user = null;
// Lock user account creation to avoid race conditions
createUserLock.lock(); // TODO: Replace this with a more stable solution which supports better concurrency
try {
Optional<User> userDataByName = findUserDataByName(username);
if (!userDataByName.isPresent()) {
LOGGER.debug("Creating new user {}", username);
user = userSupplier.apply(username);
byte[] salt = CryptoUtils.generateRandom(CryptoUtils.DEFAULT_SALT_LENGTH);
byte[] hashedPassword = hashPassword(unhashedPassword, salt);
BasicAuthData basicAuthData = new BasicAuthData(hashedPassword, salt);
try {
this.storageService.create(BASIC_AUTH_NAMESPACE, username, basicAuthData);
this.storageService.create(GENERAL_USER_NAMESPACE, username, user);
LOGGER.debug("Created new user {}", username);
} catch (StorageBackendException | StorageOperationException e) {
LOGGER.error("Unexpected error while creating new user", e);
}
}
} finally {
createUserLock.unlock();
}
return user;
}
/**
* Creates a new technical user with a random user name.
*
* @param unhashedPassword
* The unhashed password for the new technical user.
* @return A new technical user.
*/
@NotNull
public User createTechnicalUser(@NotNull String unhashedPassword) {
checkNotNull(unhashedPassword, "Password may not be null");
byte[] bytes = CryptoUtils.generateRandom(TECHNICAL_USER_NAME_LENGTH);
String randomUserName = DatatypeConverter.printHexBinary(bytes);
User user = internalCreateNewUser(randomUserName, unhashedPassword, (String u) -> this.userFactory.newTechnicalUser(u));
if (user == null) {
throw new MetadictRuntimeException("New technical user was null");
}
return user;
}
/**
* Try to authenticate a user with a given username and return the authenticated {@link User} ifcorret.
*
* @param username
* The user which should be authenticated.
* @param unhashedPassword
* The unhashed password of the user.
* @return An {@link Optional} containing the user if authentication was successful. An empty optional if the
* authentication failed.
*/
@NotNull
public Optional<User> authenticateWithPassword(@NotNull String username, @NotNull String unhashedPassword) {
checkNotNull(username, "Username may not be null");
checkNotNull(unhashedPassword, "Password may not be null");
Optional<User> user = Optional.empty();
try {
Optional<BasicAuthData> hashedData = this.storageService.read(BASIC_AUTH_NAMESPACE, username, BasicAuthData.class);
if (hashedData.isPresent()) {
BasicAuthData basicAuthData = hashedData.get();
byte[] hashPassword = hashPassword(unhashedPassword, basicAuthData.getSalt());
if (Arrays.equals(basicAuthData.getHashedPassword(), hashPassword)) {
LOGGER.debug("Successfully authenticated user {}", username);
user = findUserDataByName(username);
} else {
LOGGER.debug("Authentication failed for user {}", username);
}
} else {
LOGGER.debug("No authentication data found for user {}", username);
}
} catch (StorageBackendException | StorageOperationException e) {
LOGGER.error("An unexpected error occured while trying to authenticate a user", e);
throw new MetadictRuntimeException(e);
}
return user;
}
/**
* Checks if the given user has the given role id.
*
* @param user
* The user to check.
* @param roleId
* The role to check.
* @return True if the user has the given role or false.
*/
public boolean hasRole(User user, String roleId) {
boolean hasRole = false;
for (Role role : user.getRoles()) {
if (StringUtils.equals(role.getId(), roleId)) {
hasRole = true;
break;
}
}
return hasRole;
}
/**
* Removes all data for the given username.
*
* @param username
* The name of the user to remove.
* @return True if the user could be deleted, false if not.
* @throws StorageBackendException
* Will be thrown if the deletion failed due to an error in the storage backend.
*/
public boolean removeUser(@NotNull String username) throws StorageBackendException {
boolean result = false;
Optional<User> userDataByName = findUserDataByName(username);
if (userDataByName.isPresent()) {
createUserLock.lock(); // TODO: Replace this with a more stable solution which supports better concurrency
try {
result = this.storageService.delete(BASIC_AUTH_NAMESPACE, username);
result &= this.storageService.delete(GENERAL_USER_NAMESPACE, username);
LOGGER.debug("Removed user {}", username);
} finally {
createUserLock.unlock();
}
} else {
LOGGER.debug("Tried to remove non-existing user {}", username);
}
return result;
}
/**
* Tries to find user data for a given user id.
*
* @param username
* The user id.
* @return An {@link Optional} containing the user data or an empty optional if no user could be found.
*/
@NotNull
public Optional<User> findUserDataByName(@NotNull String username) {
checkNotNull(username);
try {
return this.storageService.read(GENERAL_USER_NAMESPACE, username, User.class);
} catch (StorageBackendException | StorageOperationException e) {
LOGGER.error("An unexpected error occurred while trying to read user data", e);
throw new MetadictRuntimeException(e);
}
}
@NotNull
byte[] hashPassword(@NotNull String unhashedPassword, @NotNull byte[] salt) {
return CryptoUtils.hashPassword(unhashedPassword.toCharArray(), salt, CryptoUtils.DEFAULT_ITERATIONS, CryptoUtils.DEFAULT_KEYLENGTH);
}
}