org.diqube.im.IdentityHandler.java Source code

Java tutorial

Introduction

Here is the source code for org.diqube.im.IdentityHandler.java

Source

/**
 * diqube: Distributed Query Base.
 *
 * Copyright (C) 2015 Bastian Gloeckle
 *
 * This file is part of diqube.
 *
 * diqube is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program 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 Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package org.diqube.im;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;

import javax.annotation.PostConstruct;
import javax.inject.Inject;

import org.apache.thrift.TException;
import org.bouncycastle.crypto.digests.SHA256Digest;
import org.bouncycastle.crypto.generators.PKCS5S2ParametersGenerator;
import org.bouncycastle.crypto.params.KeyParameter;
import org.diqube.cluster.ClusterLayout;
import org.diqube.config.Config;
import org.diqube.config.ConfigKey;
import org.diqube.connection.Connection;
import org.diqube.connection.ConnectionException;
import org.diqube.connection.ConnectionPool;
import org.diqube.connection.OurNodeAddressProvider;
import org.diqube.consensus.ConsensusClient;
import org.diqube.consensus.ConsensusClient.ClosableProvider;
import org.diqube.consensus.ConsensusClient.ConsensusClusterUnavailableException;
import org.diqube.context.AutoInstatiate;
import org.diqube.im.IdentityStateMachine.DeleteUser;
import org.diqube.im.IdentityStateMachine.GetUser;
import org.diqube.im.IdentityStateMachine.SetUser;
import org.diqube.im.callback.IdentityCallbackRegistryStateMachine;
import org.diqube.im.callback.IdentityCallbackRegistryStateMachine.Register;
import org.diqube.im.callback.IdentityCallbackRegistryStateMachine.Unregister;
import org.diqube.im.callback.IdentityCallbackRegistryStateMachineImplementation;
import org.diqube.im.logout.LogoutStateMachine;
import org.diqube.im.logout.LogoutStateMachine.GetInvalidTickets;
import org.diqube.im.logout.LogoutStateMachine.Logout;
import org.diqube.im.thrift.v1.SPassword;
import org.diqube.im.thrift.v1.SPermission;
import org.diqube.im.thrift.v1.SUser;
import org.diqube.remote.query.TicketInfoUtil;
import org.diqube.remote.query.thrift.IdentityCallbackService;
import org.diqube.remote.query.thrift.IdentityService;
import org.diqube.remote.query.thrift.OptionalString;
import org.diqube.remote.query.thrift.TicketInfo;
import org.diqube.thrift.base.thrift.AuthenticationException;
import org.diqube.thrift.base.thrift.AuthorizationException;
import org.diqube.thrift.base.thrift.RNodeAddress;
import org.diqube.thrift.base.thrift.Ticket;
import org.diqube.thrift.base.util.RUuidUtil;
import org.diqube.ticket.TicketSignatureService;
import org.diqube.ticket.TicketUtil;
import org.diqube.ticket.TicketValidityService;
import org.diqube.ticket.TicketVendor;
import org.diqube.util.BouncyCastleUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.collect.Sets;

/**
 * Implementation of an identity service.
 * 
 * <p>
 * This implementation does not use {@link SUserProvider}, as it wants to work on the newest objects always.
 *
 * @author Bastian Gloeckle
 */
@AutoInstatiate
public class IdentityHandler implements IdentityService.Iface {
    private static final Logger logger = LoggerFactory.getLogger(IdentityHandler.class);

    private static final int PBKDF2_ITERATIONS = 200_000;
    private static final int SALT_LENGTH_BYTES = 64;
    private static final int HASH_LENGTH_BYTES = 64;

    private static final String TRUE = "true";

    @Config(ConfigKey.SUPERUSER)
    private String superuser;

    @Config(ConfigKey.SUPERUSER_PASSWORD)
    private String superuserPassword;

    @Config(ConfigKey.TICKET_USE_STRONG_RANDOM)
    private String useStrongRandomConfigValue;
    private boolean useStrongRandom;

    @Inject
    private ConsensusClient consensusClient;

    @Inject
    private TicketVendor ticketVendor;

    @Inject
    private ClusterLayout clusterLayout;

    @Inject
    private OurNodeAddressProvider ourNodeAddressProvider;

    @Inject
    private ConnectionPool connectionPool;

    @Inject
    private TicketValidityService ticketValidityService;

    @Inject
    private SuperuserCheckUtil superuserCheck;

    @Inject
    private TicketSignatureService ticketSignatureService;

    @Inject
    private IdentityCallbackRegistryStateMachineImplementation callbackRegistry;

    @PostConstruct
    public void initialize() {
        useStrongRandom = useStrongRandomConfigValue != null
                && useStrongRandomConfigValue.toLowerCase().trim().equals(TRUE);

        if (useStrongRandom)
            logger.info("Using STRONG random to calculate new salts for hashing passwords. Ensure that the system "
                    + "provides enough strong randomness!");
        else
            logger.info("Using normal random to calculate new salts for hashing passwords.");
    }

    @Override
    public Ticket login(String userName, String password) throws AuthenticationException, TException {
        if (userName == null || "".equals(userName.trim()))
            throw new AuthenticationException("Empty username.");

        if (password == null || "".equals(password.trim()))
            throw new AuthenticationException("Empty password.");

        if (superuserCheck.isSuperuser(userName)) {
            if (!password.equals(superuserPassword))
                throw new AuthenticationException("Invalid credentials.");

            logger.info("Successful login by superuser '{}'", userName);

            // we have a successfully authenticated superuser!
            return ticketVendor.createDefaultTicketForUser(superuser, true);
        }

        SUser user;
        try (ClosableProvider<IdentityStateMachine> p = consensusClient
                .getStateMachineClient(IdentityStateMachine.class)) {
            user = p.getClient().getUser(GetUser.local(userName));
        } catch (ConsensusClusterUnavailableException e) {
            logger.warn("Consensus cluster offline, cannot load user!", e);
            user = null;
        }
        if (user == null) {
            logger.info("User '{}' tried to login, but does not exist", userName);
            throw new AuthenticationException("Invalid credentials.");
        }

        byte[] userProvidedPassword = password.getBytes(Charset.forName("UTF-8"));
        byte[] salt = user.getPassword().getSalt();

        BouncyCastleUtil.ensureInitialized();

        PKCS5S2ParametersGenerator pbkdf2sha256 = new PKCS5S2ParametersGenerator(new SHA256Digest());
        pbkdf2sha256.init(userProvidedPassword, salt, PBKDF2_ITERATIONS);
        byte[] userProvidedHash = ((KeyParameter) pbkdf2sha256.generateDerivedParameters(HASH_LENGTH_BYTES * 8))
                .getKey();

        if (!Arrays.equals(userProvidedHash, user.getPassword().getHash())) {
            logger.info("User '{}' provided bad password for login", userName);
            throw new AuthenticationException("Invalid credentials.");
        }

        // authenticated successfully!
        Ticket res = ticketVendor.createDefaultTicketForUser(userName, false);

        logger.info("User '{}' logged in successfully! Returning new ticket {} valid until {}.", userName,
                RUuidUtil.toUuid(res.getClaim().getTicketId()), res.getClaim().getValidUntil());

        return res;
    }

    @Override
    public void logout(Ticket ticket) throws TException, AuthorizationException {
        if (!ticketSignatureService
                .isValidTicketSignature(TicketUtil.deserialize(ByteBuffer.wrap(TicketUtil.serialize(ticket))))) {
            // filter out tickets with invalid signature, since we do not want to let users flood the consensus cluster with
            // requests.
            logger.info("Someone tried to logout with an invalid ticket. Username provided in ticket is '{}'",
                    ticket.getClaim().getUsername());
            throw new AuthorizationException("Ticket signaure invalid.");
        }

        logger.info("Logging out user '{}', ticket valid until {}", ticket.getClaim().getUsername(),
                ticket.getClaim().getValidUntil());

        ticketValidityService.markTicketAsInvalid(TicketInfoUtil.fromTicket(ticket));

        // quickly (but unreliably) distribute the logout to all known cluster nodes and all interested callbacks.
        for (RNodeAddress addr : Sets.union(clusterLayout.getNodesInsecure().stream()
                .map(addr -> addr.createRemote()).collect(Collectors.toSet()),
                callbackRegistry.getRegisteredNodesInsecure())) {
            if (addr.equals(ourNodeAddressProvider.getOurNodeAddress()))
                continue;

            try (Connection<IdentityCallbackService.Iface> con = connectionPool
                    .reserveConnection(IdentityCallbackService.Iface.class, addr, null)) {
                con.getService().ticketBecameInvalid(TicketInfoUtil.fromTicket(ticket));
            } catch (ConnectionException | IOException e) {
                // swallow, as we distribute the information reliably using the state machine below.
            } catch (InterruptedException e) {
                logger.warn("Interrupted while distributing logout information.", e);
                return;
            }
        }

        // then: distribute logout reliably (but probably slower) across the consensus cluster. This will again ensure that
        // all registered callbacks are called accordingly.
        try (ClosableProvider<LogoutStateMachine> p = consensusClient
                .getStateMachineClient(LogoutStateMachine.class)) {
            p.getClient().logout(Logout.local(ticket));
        } catch (ConsensusClusterUnavailableException e) {
            throw new RuntimeException("Consensus cluster unavailable", e);
        }

        logger.info("Logout of user '{}', ticket {}, valid until {} successful.", ticket.getClaim().getUsername(),
                RUuidUtil.toUuid(ticket.getClaim().getTicketId()), ticket.getClaim().getValidUntil());
    }

    @Override
    public void changePassword(Ticket ticket, String username, String newPassword)
            throws AuthenticationException, AuthorizationException, TException {
        ticketValidityService.validateTicket(ticket);

        if (!ticket.getClaim().getUsername().equals(username) && !superuserCheck.isSuperuser(ticket))
            throw new AuthorizationException();

        if (superuserCheck.isSuperuser(username))
            throw new AuthorizationException(
                    "Superuser password cannot be changed. Change in configuration of server.");

        logger.info("Password of user '{}' is being changed, authorized by ticket of '{}'", username,
                ticket.getClaim().getUsername());

        try (ClosableProvider<IdentityStateMachine> p = consensusClient
                .getStateMachineClient(IdentityStateMachine.class)) {
            SUser user = p.getClient().getUser(GetUser.local(username));
            if (user == null)
                throw new AuthorizationException("User does not exist");

            internalSetUserPassword(user, newPassword);

            p.getClient().setUser(SetUser.local(user));
        } catch (ConsensusClusterUnavailableException e) {
            throw new RuntimeException("Consensus cluster unavailable", e);
        }
    }

    @Override
    public void changeEmail(Ticket ticket, String username, String newEmail)
            throws AuthenticationException, AuthorizationException, TException {
        ticketValidityService.validateTicket(ticket);

        if (!ticket.getClaim().getUsername().equals(username) && !superuserCheck.isSuperuser(ticket))
            throw new AuthorizationException();

        if (!superuserCheck.isSuperuser(username))
            throw new AuthorizationException(
                    "Superuser password cannot be changed. Change in configuration of server.");

        logger.info("E-Mail of user '{}' is being changed, authorized by ticket of '{}'", username,
                ticket.getClaim().getUsername());

        try (ClosableProvider<IdentityStateMachine> p = consensusClient
                .getStateMachineClient(IdentityStateMachine.class)) {
            SUser user = p.getClient().getUser(GetUser.local(username));
            if (user == null)
                throw new AuthorizationException("User does not exist");

            user.setEmail(newEmail);

            p.getClient().setUser(SetUser.local(user));
        } catch (ConsensusClusterUnavailableException e) {
            throw new RuntimeException("Consensus cluster unavailable", e);
        }
    }

    @Override
    public void addPermission(Ticket ticket, String username, String permission, OptionalString object)
            throws AuthenticationException, AuthorizationException, TException {
        ticketValidityService.validateTicket(ticket);

        if (!superuserCheck.isSuperuser(ticket))
            throw new AuthorizationException();

        if (superuserCheck.isSuperuser(username))
            throw new AuthorizationException("Superuser permissions cannot be changed.");

        logger.info("Permission ({}/{}) is added to user '{}', authorized by ticket of '{}'", permission, object,
                username, ticket.getClaim().getUsername());

        try (ClosableProvider<IdentityStateMachine> p = consensusClient
                .getStateMachineClient(IdentityStateMachine.class)) {
            SUser user = p.getClient().getUser(GetUser.local(username));
            if (user == null)
                throw new AuthorizationException("User does not exist");

            if (!user.isSetPermissions())
                user.setPermissions(new ArrayList<>());

            boolean found = false;
            for (SPermission perm : user.getPermissions())
                if (perm.getPermissionName().equals(permission)) {
                    found = true;
                    if (object.isSetValue() && !perm.isSetObjects())
                        perm.setObjects(new ArrayList<>());
                    if (object.isSetValue())
                        perm.getObjects().add(object.getValue());
                    break;
                }

            if (!found) {
                SPermission newPerm = new SPermission();
                newPerm.setPermissionName(permission);
                if (object.isSetValue()) {
                    newPerm.setObjects(new ArrayList<>());
                    newPerm.getObjects().add(object.getValue());
                }
                user.getPermissions().add(newPerm);
            }

            p.getClient().setUser(SetUser.local(user));
        } catch (ConsensusClusterUnavailableException e) {
            throw new RuntimeException("Consensus cluster unavailable", e);
        }
    }

    @Override
    public void removePermission(Ticket ticket, String username, String permission, OptionalString object)
            throws AuthenticationException, AuthorizationException, TException {
        ticketValidityService.validateTicket(ticket);

        if (!superuserCheck.isSuperuser(ticket))
            throw new AuthorizationException();

        if (superuserCheck.isSuperuser(username))
            throw new AuthorizationException("Superuser permissions cannot be changed.");

        logger.info("Permission ({}/{}) is removed from user '{}', authorized by ticket of '{}'", permission,
                object, username, ticket.getClaim().getUsername());

        try (ClosableProvider<IdentityStateMachine> p = consensusClient
                .getStateMachineClient(IdentityStateMachine.class)) {
            SUser user = p.getClient().getUser(GetUser.local(username));
            if (user == null)
                throw new AuthorizationException("User does not exist");

            if (!user.isSetPermissions())
                return;

            for (Iterator<SPermission> it = user.getPermissions().iterator(); it.hasNext();) {
                SPermission perm = it.next();
                if (perm.getPermissionName().equals(permission)) {
                    if (object.isSetValue() && !perm.isSetObjects()) {
                        // nothing to remove
                        return;
                    } else if (object.isSetValue()) {
                        if (!perm.getObjects().remove(object.getValue())) {
                            // object was not in perm.
                            return;
                        }
                    } else
                        // we want to remove the whole permission, not just an object.
                        it.remove();
                    break;
                }
            }

            p.getClient().setUser(SetUser.local(user));
        } catch (ConsensusClusterUnavailableException e) {
            throw new RuntimeException("Consensus cluster unavailable", e);
        }
    }

    @Override
    public Map<String, List<String>> getPermissions(Ticket ticket, String username)
            throws AuthenticationException, AuthorizationException, TException {
        ticketValidityService.validateTicket(ticket);

        if (!ticket.getClaim().getUsername().equals(username) && !superuserCheck.isSuperuser(ticket))
            throw new AuthorizationException();

        if (superuserCheck.isSuperuser(username))
            throw new AuthorizationException("Cannot query permissions of superuser.");

        SUser user;
        try (ClosableProvider<IdentityStateMachine> p = consensusClient
                .getStateMachineClient(IdentityStateMachine.class)) {
            user = p.getClient().getUser(GetUser.local(username));
        } catch (ConsensusClusterUnavailableException e) {
            throw new RuntimeException("Consensus cluster unavailable", e);
        }
        if (user == null)
            throw new AuthorizationException("User does not exist");

        if (!user.isSetPermissions())
            return new HashMap<>();

        Map<String, List<String>> res = new HashMap<>();
        for (SPermission perm : user.getPermissions()) {
            List<String> objects = new ArrayList<>();
            if (perm.isSetObjects())
                objects.addAll(perm.getObjects());
            res.put(perm.getPermissionName(), objects);
        }

        return res;
    }

    @Override
    public void createUser(Ticket ticket, String username, String email, String password)
            throws AuthenticationException, AuthorizationException, TException {
        ticketValidityService.validateTicket(ticket);

        if (!superuserCheck.isSuperuser(ticket))
            throw new AuthorizationException();

        if (superuserCheck.isSuperuser(username))
            throw new AuthorizationException("Superuser permissions cannot be changed.");

        logger.info("User '{}' is being created, authorized by ticket of '{}'", username,
                ticket.getClaim().getUsername());

        SUser user = new SUser();
        user.setUsername(username);
        user.setEmail(email);
        internalSetUserPassword(user, password);

        try (ClosableProvider<IdentityStateMachine> p = consensusClient
                .getStateMachineClient(IdentityStateMachine.class)) {
            p.getClient().setUser(SetUser.local(user));
        } catch (ConsensusClusterUnavailableException e) {
            throw new RuntimeException("Consensus cluster unavailable", e);
        }
    }

    @Override
    public void deleteUser(Ticket ticket, String username)
            throws AuthenticationException, AuthorizationException, TException {
        ticketValidityService.validateTicket(ticket);

        if (!superuserCheck.isSuperuser(ticket))
            throw new AuthorizationException();

        if (superuserCheck.isSuperuser(username))
            throw new AuthorizationException("Superuser cannot be deleted.");

        logger.info("User '{}' is being deleted, authorized by ticket of '{}'", username,
                ticket.getClaim().getUsername());

        try (ClosableProvider<IdentityStateMachine> p = consensusClient
                .getStateMachineClient(IdentityStateMachine.class)) {
            p.getClient().deleteUser(DeleteUser.local(username));
        } catch (ConsensusClusterUnavailableException e) {
            throw new RuntimeException("Consensus cluster unavailable", e);
        }
    }

    @Override
    public String getEmail(Ticket ticket, String username) throws TException {
        ticketValidityService.validateTicket(ticket);

        if (!ticket.getClaim().getUsername().equals(username) && !superuserCheck.isSuperuser(ticket))
            throw new AuthorizationException();

        if (superuserCheck.isSuperuser(username))
            throw new AuthorizationException("Cannot query permissions of superuser.");

        SUser user;
        try (ClosableProvider<IdentityStateMachine> p = consensusClient
                .getStateMachineClient(IdentityStateMachine.class)) {
            user = p.getClient().getUser(GetUser.local(username));
        } catch (ConsensusClusterUnavailableException e) {
            throw new RuntimeException("Consensus cluster unavailable", e);
        }
        if (user == null)
            throw new AuthorizationException("User does not exist");

        return user.getEmail();
    }

    @Override
    public void registerCallback(RNodeAddress nodeAddress) throws TException {
        try (ClosableProvider<IdentityCallbackRegistryStateMachine> p = consensusClient
                .getStateMachineClient(IdentityCallbackRegistryStateMachine.class)) {
            p.getClient().register(Register.local(nodeAddress, System.currentTimeMillis()));
        } catch (ConsensusClusterUnavailableException e) {
            throw new RuntimeException("Consensus cluster unavailable", e);
        }
        logger.info("Registered identity callback at '{}' (service level).", nodeAddress);
    }

    @Override
    public void unregisterCallback(RNodeAddress nodeAddress) throws TException {
        try (ClosableProvider<IdentityCallbackRegistryStateMachine> p = consensusClient
                .getStateMachineClient(IdentityCallbackRegistryStateMachine.class)) {
            p.getClient().unregister(Unregister.local(nodeAddress));
        } catch (ConsensusClusterUnavailableException e) {
            throw new RuntimeException("Consensus cluster unavailable", e);
        }
        logger.info("Removed identity callback at '{}' (service level).", nodeAddress);
    }

    @Override
    public List<TicketInfo> getInvalidTicketInfos() throws TException {
        List<Ticket> invalidTickets;
        try (ClosableProvider<LogoutStateMachine> p = consensusClient
                .getStateMachineClient(LogoutStateMachine.class)) {
            invalidTickets = p.getClient().getInvalidTickets(GetInvalidTickets.local());
        } catch (ConsensusClusterUnavailableException e) {
            throw new RuntimeException("Consensus cluster unavailable", e);
        }

        return invalidTickets.stream().map(t -> TicketInfoUtil.fromTicket(t)).collect(Collectors.toList());
    }

    private void internalSetUserPassword(SUser user, String newPassword) throws TException {
        BouncyCastleUtil.ensureInitialized();

        byte[] newSalt = new byte[SALT_LENGTH_BYTES];

        if (useStrongRandom) {
            try {
                SecureRandom.getInstanceStrong().nextBytes(newSalt);
            } catch (NoSuchAlgorithmException e) {
                logger.error("Internal error when calculating new salt for new password", e);
                throw new TException("Internal error.", e);
            }
        } else {
            // use non-string random.
            ThreadLocalRandom.current().nextBytes(newSalt);
        }

        PKCS5S2ParametersGenerator pbkdf2sha256 = new PKCS5S2ParametersGenerator(new SHA256Digest());
        pbkdf2sha256.init(newPassword.getBytes(Charset.forName("UTF-8")), newSalt, PBKDF2_ITERATIONS);
        byte[] newHash = ((KeyParameter) pbkdf2sha256.generateDerivedParameters(HASH_LENGTH_BYTES * 8)).getKey();

        user.setPassword(new SPassword());
        user.getPassword().setHash(newHash);
        user.getPassword().setSalt(newSalt);
    }

}