/* * User.java * * Copyright (c) 2013, Instituto Superior Técnico. All rights reserved. * * This file is part of bennu-core. * * bennu-core is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * * bennu-core is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with bennu-core. If not, see * <http://www.gnu.org/licenses/>. */ package org.fenixedu.bennu.core.domain; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.Principal; import java.security.SecureRandom; import java.security.spec.InvalidKeySpecException; import java.util.Comparator; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; import javax.crypto.SecretKeyFactory; import javax.crypto.spec.PBEKeySpec; import org.fenixedu.bennu.core.domain.exceptions.BennuCoreDomainException; import org.fenixedu.bennu.core.groups.Group; import org.joda.time.DateTime; import org.joda.time.LocalDate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import pt.ist.fenixframework.FenixFramework; import com.google.common.base.Charsets; import com.google.common.hash.Hashing; import com.google.common.io.BaseEncoding; /** * The application end user. */ public final class User extends User_Base implements Principal { private static final Logger logger = LoggerFactory.getLogger(User.class); private static final String PBKDF2_ALGORITHM = "PBKDF2WithHmacSHA512"; // The following constants may be changed without breaking existing hashes. private static final int SALT_BYTE_SIZE = 24; private static final int HASH_BYTE_SIZE = 24; private static final int PBKDF2_ITERATIONS = 150_000; private static Map<String, User> map = new ConcurrentHashMap<>(); public static final Comparator<User> COMPARATOR_BY_NAME = Comparator.comparing(User::getDisplayName).thenComparing( User::getUsername); public static interface UsernameGenerator { public String doGenerate(UserProfile parameter); } public User(UserProfile profile) { this(generateUsername(profile), profile); } public User(String username, UserProfile profile) { super(); if (findByUsername(username) != null) { throw BennuCoreDomainException.duplicateUsername(username); } setBennu(Bennu.getInstance()); setCreated(new DateTime()); setUsername(username); setProfile(profile); } @Override public String getUsername() { //FIXME: remove when the framework enables read-only slots return super.getUsername(); } @Override public DateTime getCreated() { //FIXME: remove when the framework enables read-only slots return super.getCreated(); } /** * Ensures the existence of an open (i.e. without end date) period for this user. * * @return a {@link UserLoginPeriod} instance */ public UserLoginPeriod openLoginPeriod() { return getLoginValiditySet().stream().filter(p -> p.getEndDate() == null).findAny() .orElseGet(() -> new UserLoginPeriod(this)); } /** * Creates (if not already) a login period with the given dates for this user. * * @param start The first day of the login period (inclusive) * @param end The last day of the login period (inclusive) * @return a {@link UserLoginPeriod} instance */ public UserLoginPeriod createLoginPeriod(LocalDate start, LocalDate end) { Objects.requireNonNull(start); Objects.requireNonNull(end); return getLoginValiditySet().stream().filter(p -> p.matches(start, end)).findAny() .orElseGet(() -> new UserLoginPeriod(this, start, end)); } /** * Closes any not closed period setting the end day to yesterday (to effectively close, since end date is inclusive). */ public void closeLoginPeriod() { closeLoginPeriod(LocalDate.now().minusDays(1)); } /** * Closes any not closed period setting the end day to the given day. * * @param end the last active login day */ public void closeLoginPeriod(LocalDate end) { if (getLoginValiditySet().isEmpty()) { new UserLoginPeriod(this, getCreated().toLocalDate(), end); } else { getLoginValiditySet().stream().filter(p -> !p.isClosed()).forEach(p -> p.setEndDate(end)); } } /** * Returns the expiration day for this user, that is, the last day he or she can login in the system. * * @return An optional {@link LocalDate} value that is empty when the login is open (null ended). */ public Optional<LocalDate> getExpiration() { return getLoginValiditySet().stream().min(Comparator.naturalOrder()).map(UserLoginPeriod::getEndDate); } /** * Tests whether this user can login or not * * @return true if login is possible, false otherwise */ public boolean isLoginExpired() { return getExpiration().map(p -> LocalDate.now().isAfter(p)).orElse(false); } @Override public String getName() { return getUsername(); } public String getDisplayName() { return getProfile().getDisplayName(); } public String getEmail() { return getProfile().getEmail(); } /** * Generates and returns a random password for this user. * * @return a {@code String} containing the generated password */ public String generatePassword() { final String password = UUID.randomUUID().toString().replace("-", "").substring(0, 15); changePassword(password); return password; } /** * Sets the user's password. The password is salted and hashed with a large number of iterations. Fails with {@link Error} if * the necessary cryptographic algorithm is not present in the environment. * * @param password the password to be set */ public void changePassword(final String password) { try { SecureRandom random = new SecureRandom(); byte[] salt = new byte[SALT_BYTE_SIZE]; random.nextBytes(salt); byte[] hash = pbkdf2(PBKDF2_ALGORITHM, password.toCharArray(), salt, PBKDF2_ITERATIONS, HASH_BYTE_SIZE); setPassword(PBKDF2_ALGORITHM + ":" + PBKDF2_ITERATIONS + ":" + BaseEncoding.base64().encode(salt) + ":" + BaseEncoding.base64().encode(hash)); } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { throw new Error("Please provide proper cryptographic algorithm implementation"); } } /** * Verifies that the given password matches the user's password. Fails with {@link Error} if the necessary cryptographic * algorithm is not present in the environment. * * @param password the password to verify * @return true if matches, false otherwise */ public boolean matchesPassword(final String password) { if (getPassword() == null) { return false; } if (!getPassword().contains(":")) { final String hash = Hashing.sha512().hashString(getSalt() + password, Charsets.UTF_8).toString(); return hash.equals(getPassword()); } else { try { String[] params = getPassword().split(":"); String algorithm = params[0]; int iterations = Integer.parseInt(params[1]); byte[] salt = BaseEncoding.base64().decode(params[2]); byte[] hash = BaseEncoding.base64().decode(params[3]); byte[] testHash = pbkdf2(algorithm, password.toCharArray(), salt, iterations, hash.length); return MessageDigest.isEqual(hash, testHash); } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { throw new Error("Please provide proper cryptographic algorithm implementation"); } } } public Group groupOf() { return Group.users(this); } /** * Computes the PBKDF2 hash of a password. * * @param algorithm the algorithm name * @param password the password to hash. * @param salt the salt * @param iterations the iteration count (slowness factor) * @param bytes the length of the hash to compute in bytes * @return the PBDKF2 hash of the password */ private static byte[] pbkdf2(String algorithm, char[] password, byte[] salt, int iterations, int bytes) throws NoSuchAlgorithmException, InvalidKeySpecException { PBEKeySpec spec = new PBEKeySpec(password, salt, iterations, bytes * 8); SecretKeyFactory skf = SecretKeyFactory.getInstance(algorithm); return skf.generateSecret(spec).getEncoded(); } public static User findByUsername(final String username) { if (username == null) { return null; } User match = map.computeIfAbsent(username, User::manualFind); if (match == null) { return null; } // FIXME: the second condition is there because of bug #197 in the fenix-framework if (!FenixFramework.isDomainObjectValid(match) || !match.getUsername().equals(username)) { map.remove(username, match); return findByUsername(username); } return match; } private static User manualFind(String username) { return Bennu.getInstance().getUserSet().stream().filter(user -> user.getUsername().equals(username)).findAny() .orElse(null); } public static void setUsernameGenerator(UsernameGenerator generator) { usernameGenerator = generator; } private static UsernameGenerator usernameGenerator = new UsernameGenerator() { private final AtomicInteger currentId = new AtomicInteger(0); @Override public String doGenerate(UserProfile profile) { return "bennu" + currentId.getAndIncrement(); } }; private static String generateUsername(UserProfile profile) { while (true) { String username = usernameGenerator.doGenerate(profile); if (User.findByUsername(username) == null) { logger.debug("Generated username {} for {}", username, profile); return username; } } } }