/**
* 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.ticket;
import java.nio.ByteBuffer;
import java.util.NavigableMap;
import java.util.Set;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
import javax.inject.Inject;
import org.diqube.context.AutoInstatiate;
import org.diqube.remote.query.thrift.TicketInfo;
import org.diqube.thrift.base.thrift.AuthenticationException;
import org.diqube.thrift.base.thrift.Ticket;
import org.diqube.thrift.base.thrift.TicketClaim;
import org.diqube.util.Pair;
/**
* Checks the overall validity of {@link Ticket}s.
*
* @author Bastian Gloeckle
*/
@AutoInstatiate
public class TicketValidityService {
/**
* Milliseconds duration of timeouts the cleanup method should not remove Tickets that would be invalid because of
* their claims (valid until)
*/
public static final long CLEANUP_NO_REMOVE_MOST_RECENT_MS = 10 * 60 * 1_000L; // 10 mins
/**
* From {@link TicketClaim#getValidUntil()} to {@link TicketInfo} of tickets that have been marked invalid.
*/
private NavigableMap<Long, Set<TicketInfo>> invalidTickets = new ConcurrentSkipListMap<>();
/** Internal strategy on when to try to execute cleanup. Do this randomly in 1-2% of calls by default. */
private CleanupStrategy cleanupStrategy = () -> ThreadLocalRandom.current().nextInt(128) < 2;
private ReentrantLock cleanupLock = new ReentrantLock();
/** Provides the current timestamp. Extracted for tests. */
private TimestampProvider timestampProvider = () -> System.currentTimeMillis();
@Inject
private TicketSignatureService ticketSignatureService;
/**
* Checks if a ticket is valid. For performance reasons, {@link #isTicketValid(ByteBuffer)} should be used instead of
* this one.
*
* @return true if {@link Ticket} signature is valid.
*/
public boolean isTicketValid(Ticket ticket) {
return isTicketValid(ByteBuffer.wrap(TicketUtil.serialize(ticket)));
}
/**
* Checks if a {@link Ticket} is valid. For performance reasons, {@link #isTicketValid(ByteBuffer)} should be used
* instead of this one.
*
* @throws AuthenticationException
* is thrown if the ticket is invalid.
*/
public void validateTicket(Ticket ticket) throws AuthenticationException {
if (!isTicketValid(ticket))
throw new AuthenticationException("Ticket for user '" + ticket.getClaim().getUsername() + "' is invalid!");
}
/**
* Checks if a serialized {@link Ticket} is valid.
*
* @return true if {@link Ticket} is valid.
*/
public boolean isTicketValid(ByteBuffer serializedTicket) {
Pair<Ticket, byte[]> p = TicketUtil.deserialize(serializedTicket);
return isTicketValid(p);
}
/**
* Checks if a ticket that was deserialized by {@link TicketUtil#deserialize(ByteBuffer)} is valid.
*
* @param deserializedTicket
* Result of {@link TicketUtil#deserialize(ByteBuffer)}
* @return true if Ticket is valid.
*/
public boolean isTicketValid(Pair<Ticket, byte[]> deserializedTicket) {
return isTicketValid(deserializedTicket, false, false);
}
/**
* Internal method: Check validity with optionally disabling specific checks.
*
* @param deserializedTicket
* Result of {@link TicketUtil#deserialize(ByteBuffer)}
* @param ignoreInvalidatedTickets
* true if the list of invalidated tickets (= tickets that logged out) should be ignored.
* @param ignoreSignature
* true if the signature should not be checked.
* @return <code>true</code> if valid according to the parameters.
*/
private boolean isTicketValid(Pair<Ticket, byte[]> deserializedTicket, boolean ignoreInvalidatedTickets,
boolean ignoreSignature) {
Ticket t = deserializedTicket.getLeft();
if (timestampProvider.now() > t.getClaim().getValidUntil())
// "now" is after "valid until"
return false;
if (!ignoreInvalidatedTickets) {
Set<TicketInfo> ticketsInvalid = invalidTickets.get(t.getClaim().getValidUntil());
if (ticketsInvalid != null) {
boolean ticketIdInvalid = ticketsInvalid.stream()
.anyMatch(invalidTicket -> invalidTicket.getTicketId().equals(t.getClaim().getTicketId()));
if (ticketIdInvalid)
// Ticket was marked as invalid (e.g. logout)
return false;
}
}
if (cleanupStrategy.shouldCleanup())
executeCleanup();
return ignoreSignature || ticketSignatureService.isValidTicketSignature(deserializedTicket);
}
/**
* Check if given {@link Ticket} is valid, when ignoring the local "invalidated ticket" list (= ignore the list of
* {@link Ticket}s that have logged out but would otherwise be valid).
*
* <p>
* <b>Only call this method when you're sure you want to ignore logged out tickets!</b> You most likely want to use
* {@link #isTicketValid(Ticket)}!
*
* @return <code>true</code> if ticket is valid when ignoring locally logged out tickets.
*/
public boolean isTicketValidIgnoringInvalidatedTickets(Ticket t) {
return isTicketValid(TicketUtil.deserialize(ByteBuffer.wrap(TicketUtil.serialize(t))), true, false);
}
/**
* Checks if a serialized {@link Ticket} is valid.
*
* @throws AuthenticationException
* is thrown if the ticket is invalid.
*/
public void validateTicket(ByteBuffer serializedTicket) throws AuthenticationException {
if (!isTicketValid(serializedTicket)) {
Ticket ticket = TicketUtil.deserialize(serializedTicket).getLeft();
throw new AuthenticationException("Ticket for user '" + ticket.getClaim().getUsername() + "' is invalid!");
}
}
/**
* Mark the given ticket as invalid, future calls to the validation methods will return false with the given ticket.
*
* <p>
* This should be called if a user logged out for example.
*/
public void markTicketAsInvalid(TicketInfo ticketInfo) {
Set<TicketInfo> ticketInfosInvalid =
invalidTickets.computeIfAbsent(ticketInfo.getValidUntil(), k -> new ConcurrentSkipListSet<>());
ticketInfosInvalid.add(ticketInfo);
}
/**
* @return All {@link TicketInfo}s currently marked as invalid. Note that tickets that were passed to
* {@link #markTicketAsInvalid(TicketInfo)} will be cleaned up from time to time, if their "valid until" value
* has passed. So this method might not return those Tickets that because invalid because of the values of
* their claim anyway.
*/
public Set<TicketInfo> getInvalidTicketInfos() {
Set<TicketInfo> res = invalidTickets.values().stream().flatMap(s -> s.stream()).collect(Collectors.toSet());
return res;
}
private void executeCleanup() {
if (cleanupLock.tryLock()) {
try {
invalidTickets.headMap(timestampProvider.now() - CLEANUP_NO_REMOVE_MOST_RECENT_MS).clear();
} finally {
cleanupLock.unlock();
}
}
}
// for tests
/* package */ void setTimestampProvider(TimestampProvider timestampProvider) {
this.timestampProvider = timestampProvider;
}
/* package */ static interface CleanupStrategy {
boolean shouldCleanup();
}
/* package */ static interface TimestampProvider {
long now();
}
}