Java tutorial
/** * 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); } }