UserManager.java
/*
* The coLAB project
* Copyright (C) 2021-2023 AlbaSim, MEI, HEIG-VD, HES-SO
*
* Licensed under the MIT License
*/
package ch.colabproject.colab.api.controller.user;
import ch.colabproject.colab.api.Helper;
import ch.colabproject.colab.api.controller.RequestManager;
import ch.colabproject.colab.api.controller.ValidationManager;
import ch.colabproject.colab.api.controller.team.InstanceMakerManager;
import ch.colabproject.colab.api.controller.team.TeamManager;
import ch.colabproject.colab.api.controller.token.TokenManager;
import ch.colabproject.colab.api.exceptions.ColabMergeException;
import ch.colabproject.colab.api.model.user.Account;
import ch.colabproject.colab.api.model.user.AuthInfo;
import ch.colabproject.colab.api.model.user.AuthMethod;
import ch.colabproject.colab.api.model.user.HashMethod;
import ch.colabproject.colab.api.model.user.HttpSession;
import ch.colabproject.colab.api.model.user.LocalAccount;
import ch.colabproject.colab.api.model.user.SignUpInfo;
import ch.colabproject.colab.api.model.user.User;
import ch.colabproject.colab.api.persistence.jpa.user.AccountDao;
import ch.colabproject.colab.api.persistence.jpa.user.HttpSessionDao;
import ch.colabproject.colab.api.persistence.jpa.user.UserDao;
import ch.colabproject.colab.api.security.AuthenticationFailure;
import ch.colabproject.colab.api.security.SessionManager;
import ch.colabproject.colab.generator.model.exceptions.HttpErrorMessage;
import ch.colabproject.colab.generator.model.exceptions.MessageI18nKey;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import javax.ejb.LocalBean;
import javax.ejb.Stateless;
import javax.ejb.TransactionAttribute;
import javax.ejb.TransactionAttributeType;
import javax.inject.Inject;
import com.google.common.collect.Lists;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Everything related to user management
*
* @author maxence
*/
@LocalBean
@Stateless
public class UserManager {
/**
* Default size of salt in bytes
*/
private static final int SALT_LENGTH = 32;
/**
* Max number of failed authentication allowed
*/
private static final Long AUTHENTICATION_ATTEMPT_MAX = 25L;
/**
* Number of second to wait to accept new authentication attempt if max number has been reached
*/
private static final Long AUTHENTICATION_ATTEMPT_RESET_DELAY_SEC = 60 * 15L; // 15min
/** logger */
private static final Logger logger = LoggerFactory.getLogger(UserManager.class);
/**
* Request related logic
*/
@Inject
private RequestManager requestManager;
/** Session manager to keep trace of authentication attempts */
@Inject
private SessionManager sessionManager;
/**
* some operation on users will create and send tokens
*/
@Inject
private TokenManager tokenManager;
/**
* Entity validation management
*/
@Inject
private ValidationManager validationManager;
/** Team specific logic management */
@Inject
private TeamManager teamManager;
/** InstanceMaker specific logic management */
@Inject
private InstanceMakerManager instanceMakerManager;
/** Account persistence handling */
@Inject
private AccountDao accountDao;
/** User persistence handling */
@Inject
private UserDao userDao;
/** Http session persistence handling */
@Inject
private HttpSessionDao httpSessionDao;
// *********************************************************************************************
// find user
// *********************************************************************************************
/**
* Retrieve the user. If not found, throw a {@link HttpErrorMessage}.
*
* @param userId the id of the user
*
* @return the user if found
*
* @throws HttpErrorMessage if the user was not found
*/
public User assertAndGetUser(Long userId) {
User user = userDao.findUser(userId);
if (user == null) {
logger.error("user #{} not found", userId);
throw HttpErrorMessage.dataError(MessageI18nKey.DATA_NOT_FOUND);
}
return user;
}
/**
* Find a user by id
*
* @param id id of the user
*
* @return the user or null if user not exist or current user has not right to read the user
*/
public User getUserById(Long id) {
return userDao.findUser(id);
}
/**
* Get the users related to the given project
*
* @param projectId the id of the project
*
* @return users list
*/
public List<User> getUsersForProject(Long projectId) {
logger.debug("Get users of project #{}", projectId);
List<User> teamMembers = teamManager.getUsersForProject(projectId);
List<User> instanceMakers = instanceMakerManager.getUsersForProject(projectId);
List<User> allUsers = Lists.newArrayList();
allUsers.addAll(teamMembers);
allUsers.addAll(instanceMakers);
return allUsers;
}
/**
* Which authentication method and parameters should a user use to authenticate. If the
* identifier is an email address, it will return authMethod which match the localAccount linked
* with the email address. Otherwise, identifier is used as a username and the first
* LocalAccount of the user is used.
* <p>
* In case no LocalAccount has been found, authentication method with random parameters is
* returned. Such parameters may be used by clients to create brand-new account. This behavior
* prevents to easy account existence leaks.
*
* @param identifier {@link LocalAccount } email address or {@link User} username
*
* @return authentication method to use to authentication as email owner or new random one which
* can be used to create a brand new localAccount
*
* @throws HttpErrorMessage badRequest if there is no identifier
*/
public AuthMethod getAuthenticationMethod(String identifier) {
try {
return requestManager.sudo(() -> {
if (identifier == null || identifier.isBlank()) {
throw HttpErrorMessage.badRequest();
} else {
LocalAccount account = findLocalAccountByIdentifier(identifier);
if (account != null) {
return new AuthMethod(account.getCurrentClientHashMethod(),
account.getClientSalt(),
account.getNextClientHashMethod(), account.getNewClientSalt());
} else {
// no account found, return random method
// TODO: store it in a tmp cache
return this.getDefaultRandomAuthenticationMethod();
}
}
});
} catch (Exception e) {
return this.getDefaultRandomAuthenticationMethod();
}
}
/**
* Get default hash method with random salt
*
* @return a hash method and its parameters
*/
public AuthMethod getDefaultRandomAuthenticationMethod() {
return new AuthMethod(Helper.getDefaultHashMethod(), Helper
.generateHexSalt(SALT_LENGTH), null, null);
}
/**
* {@link #createAdminUser(String, String, String)} within a brand-new transaction.
*
* @param username username
* @param email email address
* @param plainPassword plain text password
*
* @return a brand-new user to rule them all
*/
@TransactionAttribute(TransactionAttributeType.REQUIRES_NEW)
public User createAdminUserTx(String username, String email, String plainPassword) {
return this.createAdminUser(username, email, plainPassword);
}
/**
* Create a brand-new admin user, which can authenticate with a {@link LocalAccount}.
* <p>
* First create the user and the local account, then authenticate and grant admin rights.
*
* @param username username
* @param email email address
* @param plainPassword plain text password
*
* @return a brand-new user to rule them all
*
* @throws HttpErrorMessage if username is already taken
*/
private User createAdminUser(String username, String email, String plainPassword) {
User admin = this.createUserWithLocalAccount(username, username /* username is also used as firstname */, email, plainPassword);
LocalAccount account = (LocalAccount) admin.getAccounts().get(0);
AuthInfo authInfo = new AuthInfo();
authInfo.setIdentifier(username);
authInfo.setMandatoryHash(
Helper.bytesToHex(
account.getCurrentClientHashMethod().hash(
plainPassword,
account.getClientSalt())));
this.authenticate(authInfo);
this.grantAdminRight(admin.getId());
return admin;
}
/**
* Create a brand-new user, which can authenticate with a {@link LocalAccount}.First,
* plainPassword will be hashed as any client should do. Then the
* {@link #signup(SignUpInfo) signup} method is called.
*
* @param username username
* @param firstname first name
* @param email email address
* @param plainPassword plain text password
*
* @return a brand-new user
*
* @throws HttpErrorMessage if username is already taken
*/
private User createUserWithLocalAccount(String username, String firstname, String email, String plainPassword) {
AuthMethod method = getDefaultRandomAuthenticationMethod();
byte[] hash = method.getMandatoryMethod().hash(plainPassword, method.getSalt());
SignUpInfo signUpInfo = new SignUpInfo();
signUpInfo.setUsername(username);
signUpInfo.setFirstname(firstname);
signUpInfo.setEmail(email);
signUpInfo.setHashMethod(method.getMandatoryMethod());
signUpInfo.setSalt(method.getSalt());
signUpInfo.setHash(Helper.bytesToHex(hash));
return this.signup(signUpInfo);
}
/**
* Create a new user with local account. An e-mail will be sent to user to verify its account
*
* @param signup all info to create a new account
*
* @return brand-new user embedding an LocalAccount
*
* @throws HttpErrorMessage if username is already taken
*/
public User signup(SignUpInfo signup) {
// username already taken ?
User user = userDao.findUserByUsername(signup.getUsername());
if (user == null) {
// username not yet taken
LocalAccount account = accountDao.findLocalAccountByEmail(signup.getEmail());
// no local account with the given email address
if (account == null) {
if (!Helper.isEmailAddress(signup.getEmail())) {
throw HttpErrorMessage.signUpFailed(MessageI18nKey.EMAIL_NOT_VALID);
}
account = new LocalAccount();
account.setClientSalt(signup.getSalt());
account.setCurrentClientHashMethod(signup.getHashMethod());
account.setEmail(signup.getEmail());
account.setVerified(false);
account.setCurrentDbHashMethod(Helper.getDefaultHashMethod());
this.shadowHash(account, signup.getHash());
user = new User();
user.getAccounts().add((account));
account.setUser(user);
user.setUsername(signup.getUsername());
user.setFirstname(signup.getFirstname());
user.setLastname(signup.getLastname());
user.setAffiliation(signup.getAffiliation());
user.setAgreedTime(OffsetDateTime.now());
validationManager.assertValid(user);
validationManager.assertValid(account);
User persistedUser = userDao.persistUser(user);
// flush changes to DB to check DB constraint
// TODO: build some AfterTXCommit executor
requestManager.flush();
// new user with a local account should verify their e-mail address
tokenManager.requestEmailAddressVerification(account, false);
return persistedUser;
} else {
// wait.... throwing something else here leaks account existence...
// for security reason, give as little useful information as possible
// the user is not allowed to know if the error concerns the username or the email
// address
throw HttpErrorMessage.signUpFailed(MessageI18nKey.IDENTIFIER_ALREADY_TAKEN);
}
} else {
// for security reason, give as little useful information as possible
// the user is not allowed to know if the error concerns the username or the email
// address
throw HttpErrorMessage.signUpFailed(MessageI18nKey.IDENTIFIER_ALREADY_TAKEN);
}
}
/**
* Try to authenticate user with given token
*
* @param authInfo authentication information
*
* @return just authenticated user of null is authentication did not succeed
*
* @throws HttpErrorMessage if authentication failed
*/
public User authenticate(AuthInfo authInfo) {
LocalAccount account = findLocalAccountByIdentifier(authInfo.getIdentifier());
if (account != null) {
HashMethod m = account.getCurrentDbHashMethod();
String mandatoryHash = authInfo.getMandatoryHash();
if (mandatoryHash != null) {
byte[] hash = m.hash(mandatoryHash, account.getDbSalt());
AuthenticationFailure aa = sessionManager.getAuthenticationAttempt(account);
if (aa != null) {
logger.warn("Attempt: {}", aa.getCounter());
if (aa.getCounter() >= AUTHENTICATION_ATTEMPT_MAX) {
// max number of failed attempts reached
OffsetDateTime lastAttempt = aa.getTimestamp();
OffsetDateTime delay = lastAttempt
.plusSeconds(AUTHENTICATION_ATTEMPT_RESET_DELAY_SEC);
if (OffsetDateTime.now().isAfter(delay)) {
// delay has been reached, user may try again
sessionManager.resetAuthenticationAttemptHistory(account);
} else {
// user have to wait some time before any new attempt
logger.warn(
"Account {} reached the max number of failed authentication",
account);
throw HttpErrorMessage.tooManyAttempts();
}
}
}
// Spotbugs reports a timing attack vulnerability using:
// if (Arrays.equals(hash, account.getHashedPassword())) {
// doing a full comparison of arrays makes it happy:
if (Helper.constantTimeArrayEquals(hash, account.getHashedPassword())) {
// authentication succeed
/////////////////////////////////
sessionManager.resetAuthenticationAttemptHistory(account);
boolean forceShadow = false;
// should rotate client method ?
if (account.getNextClientHashMethod() != null
&& authInfo.getOptionalHash() != null) {
// rotate method
account.setClientSalt(account.getNewClientSalt());
account.setNewClientSalt(null);
account.setCurrentClientHashMethod(account.getNextClientHashMethod());
account.setNextClientHashMethod(null);
// rotate provided hash and force to compute and save db hash
mandatoryHash = authInfo.getOptionalHash();
forceShadow = true;
}
// should rotate server method ?
if (account.getNextDbHashMethod() != null) {
// rotate method
account.setCurrentDbHashMethod(account.getNextDbHashMethod());
account.setNextDbHashMethod(null);
// force to compute and save db hash
forceShadow = true;
}
if (forceShadow) {
this.shadowHash(account, mandatoryHash);
}
requestManager.login(account);
return account.getUser();
} else {
// update cache of failed authentication attempt
sessionManager.authenticationFailure(account);
}
}
}
// authentication fails
// OR client did not provide required hash
// OR account not found
throw HttpErrorMessage.authenticationFailed();
}
/**
* Update password of the given account. The given password should be hashed by the client
*
* @param authInfo contains account identifier and new password
*
* @throws HttpErrorMessage if currentUser is not allowed to update the given account
*/
public void updatePassword(AuthInfo authInfo) {
LocalAccount account = findLocalAccountByIdentifier(authInfo.getIdentifier());
if (account != null) {
String mandatoryHash = authInfo.getMandatoryHash();
if (mandatoryHash != null) {
// should rotate server method ?
if (account.getNextDbHashMethod() != null) {
// rotate method
account.setCurrentDbHashMethod(account.getNextDbHashMethod());
account.setNextDbHashMethod(null);
// force to compute and save db hash
}
this.shadowHash(account, mandatoryHash);
return;
}
}
throw HttpErrorMessage.forbidden();
}
/**
* hash given client-side hash to dbHash and store it
*
* @param account account to update the hash in
* @param hash hash (ie account.clientMethod.hash(clientSalt + plain_password))
*/
private void shadowHash(LocalAccount account, String hash) {
// use a new salt
account.setDbSalt(Helper.generateSalt(SALT_LENGTH));
// compute new hash and save it
byte[] newHash = account.getCurrentDbHashMethod().hash(hash, account.getDbSalt());
account.setHashedPassword(newHash);
}
/**
* Log current user out
*/
public void logout() {
// clear account from http session
this.requestManager.logout();
}
/**
* Force session logout
*
* @param sessionId id of session to logout
*/
public void forceLogout(Long sessionId) {
HttpSession httpSession = httpSessionDao.findHttpSession(sessionId);
HttpSession currentSession = requestManager.getHttpSession();
if (httpSession != null) {
if (!httpSession.equals(currentSession)) {
requestManager.sudo(() -> sessionManager.deleteHttpSession(httpSession));
} else {
throw HttpErrorMessage.badRequest();
}
}
}
/**
* Grant admin right to a user.
*
* @param user user who will become an admin
*/
public void grantAdminRight(User user) {
user.setAdmin(true);
}
/**
* Grant admin right to a user.
*
* @param id id of user who will become an admin
*/
public void grantAdminRight(Long id) {
this.grantAdminRight(userDao.findUser(id));
}
/**
* Revoke admin right to a user.
*
* @param id id of user who will not be an admin any longer
*/
public void revokeAdminRight(Long id) {
this.revokeAdminRight(userDao.findUser(id));
}
/**
* revoke admin right to a user.
*
* @param user user who will not be an admin any longer
*/
public void revokeAdminRight(User user) {
if (user != null) {
User currentUser = requestManager.getCurrentUser();
if (user.equals(currentUser)) {
// user shall not remove admin right to itself
throw HttpErrorMessage.badRequest();
} else {
user.setAdmin(false);
}
}
}
/**
* Setup new client hash method to use for the given local account
*
* @param id id of the LocalAccount
*/
public void switchClientHashMethod(Long id) {
Account account = accountDao.findAccount(id);
if (account instanceof LocalAccount) {
LocalAccount localAccount = (LocalAccount) account;
AuthMethod authMethod = getDefaultRandomAuthenticationMethod();
localAccount.setNextClientHashMethod(authMethod.getMandatoryMethod());
localAccount.setNewClientSalt(authMethod.getSalt());
}
}
/**
* Setup new server hash method to use for the given local account
*
* @param id id of the LocalAccount
*/
public void switchServerHashMethod(Long id) {
Account account = accountDao.findAccount(id);
if (account instanceof LocalAccount) {
LocalAccount localAccount = (LocalAccount) account;
AuthMethod authMethod = getDefaultRandomAuthenticationMethod();
localAccount.setNextDbHashMethod(authMethod.getMandatoryMethod());
}
}
/**
* Find local account by identifier.
*
* @param identifier email address or username
*
* @return LocalAccount
*/
public LocalAccount findLocalAccountByIdentifier(String identifier) {
LocalAccount account = accountDao.findLocalAccountByEmail(identifier);
if (account == null) {
// no localAccount with such an email address
// try to find a user by username
User user = userDao.findUserByUsername(identifier);
if (user != null) {
// User found, as authenticationMethod is only available for LocalAccount,
// try to find one
Optional<Account> optAccount = user.getAccounts().stream()
.filter(a -> a instanceof LocalAccount)
.findFirst();
if (optAccount.isPresent()) {
account = (LocalAccount) optAccount.get();
}
}
}
return account;
}
/**
* If the given email address is linked to a local account, sent a link to this mailbox to reset
* the account password.
*
* @param email email address used as account identifier
*/
public void requestPasswordReset(String email) {
logger.debug("Request reset password: {}", email);
LocalAccount account = accountDao.findLocalAccountByEmail(email);
if (account != null) {
// account exists, send the message
tokenManager.sendResetPasswordToken(account, true);
}
}
/**
* Update the account with values provided in given account.Only field which are editable by
* users will be impacted.
*
* @param account account new values
*
* @return updated account
*
* @throws HttpErrorMessage if the provided email is not a valid email
* @throws ColabMergeException if something went wrong
*/
public LocalAccount updateLocalAccountEmailAddress(LocalAccount account)
throws ColabMergeException {
logger.debug("Update LocalAccount email address: {}", account);
LocalAccount managedAccount = (LocalAccount) accountDao.findAccount(account.getId());
String currentEmail = managedAccount.getEmail();
String newEmail = account.getEmail();
if (newEmail != null && !newEmail.equals(currentEmail)) {
if (!Helper.isEmailAddress(newEmail)) {
throw HttpErrorMessage.dataError(MessageI18nKey.DATA_INTEGRITY_FAILURE);
}
try {
managedAccount.setVerified(false);
managedAccount.setEmail(newEmail);
// make sure to flush changes to database. It will check index uniqueness
requestManager.flush();
tokenManager.requestEmailAddressVerification(account, false);
} catch (Exception e) {
// address already used, do not send any email to this address
logger.error("Exception", e);
}
}
return managedAccount;
}
/**
* Set the account as verified
*
* @param account the account to change
*/
public void setLocalAccountAsVerified(LocalAccount account) {
account.setVerified(Boolean.TRUE);
}
/**
* Update the user agreedTime to now
*
* @param userId id of the user to update
*/
public void updateUserAgreedTime(Long userId) {
User user = assertAndGetUser(userId);
OffsetDateTime now = OffsetDateTime.now();
user.setAgreedTime(now);
}
/**
* Get all session linked to the current user
*
* @return list of all active sessions
*/
public List<HttpSession> getCurrentUserActiveHttpSessions() {
return requestManager.getCurrentUser().getAccounts().stream()
.flatMap(account -> account.getHttpSessions().stream()).collect(Collectors.toList());
}
}