/**
* This file is part of alf.io.
*
* alf.io 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.
*
* alf.io 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 alf.io. If not, see <http://www.gnu.org/licenses/>.
*/
package alfio.manager;
import alfio.manager.support.CheckInStatus;
import alfio.manager.support.DefaultCheckInResult;
import alfio.manager.support.OnSitePaymentResult;
import alfio.manager.support.TicketAndCheckInResult;
import alfio.model.Event;
import alfio.model.FullTicketInfo;
import alfio.model.Ticket;
import alfio.model.Ticket.TicketStatus;
import alfio.model.TicketReservation;
import alfio.model.audit.ScanAudit;
import alfio.model.transaction.PaymentProxy;
import alfio.repository.EventRepository;
import alfio.repository.TicketFieldRepository;
import alfio.repository.TicketRepository;
import alfio.repository.TicketReservationRepository;
import alfio.repository.audit.ScanAuditRepository;
import alfio.util.Json;
import alfio.util.MonetaryUtil;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;
import org.apache.commons.lang3.tuple.Pair;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.time.ZonedDateTime;
import java.util.*;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static alfio.manager.support.CheckInStatus.*;
import static alfio.util.OptionalWrapper.optionally;
@Component
@Transactional
@Log4j2
public class CheckInManager {
private final TicketRepository ticketRepository;
private final EventRepository eventRepository;
private final TicketReservationRepository ticketReservationRepository;
private final TicketFieldRepository ticketFieldRepository;
private final ScanAuditRepository scanAuditRepository;
@Autowired
public CheckInManager(TicketRepository ticketRepository,
EventRepository eventRepository,
TicketReservationRepository ticketReservationRepository,
TicketFieldRepository ticketFieldRepository,
ScanAuditRepository scanAuditRepository) {
this.ticketRepository = ticketRepository;
this.eventRepository = eventRepository;
this.ticketReservationRepository = ticketReservationRepository;
this.ticketFieldRepository = ticketFieldRepository;
this.scanAuditRepository = scanAuditRepository;
}
private void checkIn(String uuid) {
Ticket ticket = ticketRepository.findByUUID(uuid);
Validate.isTrue(ticket.getStatus() == TicketStatus.ACQUIRED);
ticketRepository.updateTicketStatusWithUUID(uuid, TicketStatus.CHECKED_IN.toString());
ticketRepository.toggleTicketLocking(ticket.getId(), ticket.getCategoryId(), true);
}
private void acquire(String uuid) {
Validate.isTrue(ticketRepository.findByUUID(uuid).getStatus() == TicketStatus.TO_BE_PAID);
ticketRepository.updateTicketStatusWithUUID(uuid, TicketStatus.ACQUIRED.toString());
}
public TicketAndCheckInResult confirmOnSitePayment(String eventName, String ticketIdentifier, Optional<String> ticketCode, String user) {
return eventRepository.findOptionalByShortName(eventName)
.flatMap(e -> confirmOnSitePayment(ticketIdentifier).map((String s) -> Pair.of(s, e)))
.map(p -> checkIn(p.getRight().getId(), ticketIdentifier, ticketCode, user))
.orElseGet(() -> new TicketAndCheckInResult(null, new DefaultCheckInResult(CheckInStatus.TICKET_NOT_FOUND, "")));
}
public Optional<String> confirmOnSitePayment(String ticketIdentifier) {
Optional<String> uuid = findAndLockTicket(ticketIdentifier)
.filter(t -> t.getStatus() == TicketStatus.TO_BE_PAID)
.map(Ticket::getUuid);
uuid.ifPresent(this::acquire);
return uuid;
}
public TicketAndCheckInResult checkIn(String shortName, String ticketIdentifier, Optional<String> ticketCode, String user) {
return eventRepository.findOptionalByShortName(shortName).map(e -> checkIn(e.getId(), ticketIdentifier, ticketCode, user)).orElseGet(() -> new TicketAndCheckInResult(null, new DefaultCheckInResult(CheckInStatus.EVENT_NOT_FOUND, "event not found")));
}
public TicketAndCheckInResult checkIn(int eventId, String ticketIdentifier, Optional<String> ticketCode, String user) {
TicketAndCheckInResult descriptor = extractStatus(eventId, ticketRepository.findByUUIDForUpdate(ticketIdentifier), ticketIdentifier, ticketCode);
if(descriptor.getResult().getStatus() == OK_READY_TO_BE_CHECKED_IN) {
checkIn(ticketIdentifier);
scanAuditRepository.insert(ticketIdentifier, eventId, ZonedDateTime.now(), user, SUCCESS, ScanAudit.Operation.SCAN);
return new TicketAndCheckInResult(descriptor.getTicket(), new DefaultCheckInResult(SUCCESS, "success"));
}
return descriptor;
}
public boolean manualCheckIn(int eventId, String ticketIdentifier, String user) {
Optional<Ticket> ticket = findAndLockTicket(ticketIdentifier);
return ticket.map((t) -> {
if(t.getStatus() == TicketStatus.TO_BE_PAID) {
acquire(ticketIdentifier);
}
checkIn(ticketIdentifier);
scanAuditRepository.insert(ticketIdentifier, eventId, ZonedDateTime.now(), user, SUCCESS, ScanAudit.Operation.SCAN);
return true;
}).orElse(false);
}
public boolean revertCheckIn(int eventId, String ticketIdentifier, String user) {
return findAndLockTicket(ticketIdentifier).map((t) -> {
if(t.getStatus() == TicketStatus.CHECKED_IN) {
TicketReservation reservation = ticketReservationRepository.findReservationById(t.getTicketsReservationId());
TicketStatus revertedStatus = reservation.getPaymentMethod() == PaymentProxy.ON_SITE ? TicketStatus.TO_BE_PAID : TicketStatus.ACQUIRED;
ticketRepository.updateTicketStatusWithUUID(ticketIdentifier, revertedStatus.toString());
scanAuditRepository.insert(ticketIdentifier, eventId, ZonedDateTime.now(), user, OK_READY_TO_BE_CHECKED_IN, ScanAudit.Operation.REVERT);
return true;
}
return false;
}).orElse(false);
}
private Optional<Ticket> findAndLockTicket(String uuid) {
return ticketRepository.findByUUIDForUpdate(uuid);
}
public List<FullTicketInfo> findAllFullTicketInfo(int eventId) {
return ticketRepository.findAllFullTicketInfoAssignedByEventId(eventId);
}
public TicketAndCheckInResult evaluateTicketStatus(int eventId, String ticketIdentifier, Optional<String> ticketCode) {
return extractStatus(optionally(() -> eventRepository.findById(eventId)), optionally(() -> ticketRepository.findByUUID(ticketIdentifier)), ticketIdentifier, ticketCode);
}
public TicketAndCheckInResult evaluateTicketStatus(String eventName, String ticketIdentifier, Optional<String> ticketCode) {
return extractStatus(eventRepository.findOptionalByShortName(eventName), optionally(() -> ticketRepository.findByUUID(ticketIdentifier)), ticketIdentifier, ticketCode);
}
private TicketAndCheckInResult extractStatus(int eventId, Optional<Ticket> maybeTicket, String ticketIdentifier, Optional<String> ticketCode) {
return extractStatus(optionally(() -> eventRepository.findById(eventId)), maybeTicket, ticketIdentifier, ticketCode);
}
private TicketAndCheckInResult extractStatus(Optional<Event> maybeEvent, Optional<Ticket> maybeTicket, String ticketIdentifier, Optional<String> ticketCode) {
if (!maybeEvent.isPresent()) {
return new TicketAndCheckInResult(null, new DefaultCheckInResult(EVENT_NOT_FOUND, "Event not found"));
}
if (!maybeTicket.isPresent()) {
return new TicketAndCheckInResult(null, new DefaultCheckInResult(TICKET_NOT_FOUND, "Ticket with uuid " + ticketIdentifier + " not found"));
}
if(!ticketCode.filter(StringUtils::isNotEmpty).isPresent()) {
return new TicketAndCheckInResult(null, new DefaultCheckInResult(EMPTY_TICKET_CODE, "Missing ticket code"));
}
Ticket ticket = maybeTicket.get();
Event event = maybeEvent.get();
String code = ticketCode.get();
log.trace("scanned code is {}", code);
log.trace("true code is {}", ticket.ticketCode(event.getPrivateKey()));
if (!code.equals(ticket.ticketCode(event.getPrivateKey()))) {
return new TicketAndCheckInResult(null, new DefaultCheckInResult(INVALID_TICKET_CODE, "Ticket qr code does not match"));
}
final TicketStatus ticketStatus = ticket.getStatus();
if (ticketStatus == TicketStatus.TO_BE_PAID) {
return new TicketAndCheckInResult(ticket, new OnSitePaymentResult(MUST_PAY, "Must pay for ticket", MonetaryUtil.centsToUnit(ticket.getFinalPriceCts()), event.getCurrency()));
}
if (ticketStatus == TicketStatus.CHECKED_IN) {
return new TicketAndCheckInResult(ticket, new DefaultCheckInResult(ALREADY_CHECK_IN, "Error: already checked in"));
}
if (ticket.getStatus() != TicketStatus.ACQUIRED) {
return new TicketAndCheckInResult(ticket, new DefaultCheckInResult(INVALID_TICKET_STATE, "Invalid ticket state, expected ACQUIRED state, received " + ticket.getStatus()));
}
return new TicketAndCheckInResult(ticket, new DefaultCheckInResult(OK_READY_TO_BE_CHECKED_IN, "Ready to be checked in"));
}
private static Pair<Cipher, SecretKeySpec> getCypher(String key) {
try {
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
int iterations = 1000;
int keyLength = 256;
PBEKeySpec spec = new PBEKeySpec(key.toCharArray(), key.getBytes(StandardCharsets.UTF_8), iterations, keyLength);
SecretKey secretKey = factory.generateSecret(spec);
SecretKeySpec secret = new SecretKeySpec(secretKey.getEncoded(), "AES");
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
return Pair.of(cipher, secret);
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
static String encrypt(String key, String payload) {
try {
Pair<Cipher, SecretKeySpec> cipherAndSecret = getCypher(key);
Cipher cipher = cipherAndSecret.getKey();
cipher.init(Cipher.ENCRYPT_MODE, cipherAndSecret.getRight());
byte[] data = cipher.doFinal(payload.getBytes(StandardCharsets.UTF_8));
byte[] iv = cipher.getIV();
return Base64.encodeBase64URLSafeString(iv) + "|" + Base64.encodeBase64URLSafeString(data);
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
static String decrypt(String key, String payload) {
try {
Pair<Cipher, SecretKeySpec> cipherAndSecret = getCypher(key);
Cipher cipher = cipherAndSecret.getKey();
String[] splitted = payload.split(Pattern.quote("|"));
byte[] iv = Base64.decodeBase64(splitted[0]);
byte[] body = Base64.decodeBase64(splitted[1]);
cipher.init(Cipher.DECRYPT_MODE, cipherAndSecret.getRight(), new IvParameterSpec(iv));
byte[] decrypted = cipher.doFinal(body);
return new String(decrypted, StandardCharsets.UTF_8);
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
public Map<String,String> getEncryptedAttendeesInformation(String eventName, Set<String> additionalFields) {
return eventRepository.findOptionalByShortName(eventName).map(event -> {
String eventKey = event.getPrivateKey();
Function<FullTicketInfo, String> hashedHMAC = ticket -> DigestUtils.sha256Hex(ticket.hmacTicketInfo(eventKey));
Function<FullTicketInfo, String> encryptedBody = ticket -> {
Map<String, String> info = new HashMap<>();
info.put("firstName", ticket.getFirstName());
info.put("lastName", ticket.getLastName());
info.put("fullName", ticket.getFullName());
info.put("email", ticket.getEmail());
info.put("status", ticket.getStatus().toString());
info.put("uuid", ticket.getUuid());
if(!additionalFields.isEmpty()) {
ticketFieldRepository.findValueForTicketId(ticket.getId(), additionalFields)
.forEach(field -> info.put(field.getName(), field.getValue()));
}
String key = ticket.ticketCode(eventKey);
return encrypt(key, Json.toJson(info));
};
return ticketRepository.findAllFullTicketInfoAssignedByEventId(event.getId())
.stream()
.collect(Collectors.toMap(hashedHMAC, encryptedBody));
}).orElse(Collections.emptyMap());
}
}