Skip to content

Commit 4393692

Browse files
committed
#597 - first draft of web service
1 parent aecd8ea commit 4393692

File tree

5 files changed

+198
-47
lines changed

5 files changed

+198
-47
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* This file is part of alf.io.
3+
*
4+
* alf.io is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* alf.io is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with alf.io. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
package alfio.controller.api.pass;
18+
19+
import alfio.manager.PassKitManager;
20+
import alfio.model.EventAndOrganizationId;
21+
import alfio.model.Ticket;
22+
import lombok.RequiredArgsConstructor;
23+
import lombok.extern.log4j.Log4j2;
24+
import org.apache.commons.lang3.tuple.Pair;
25+
import org.springframework.http.ResponseEntity;
26+
import org.springframework.web.bind.annotation.*;
27+
28+
import javax.servlet.http.HttpServletResponse;
29+
import java.io.IOException;
30+
import java.util.Optional;
31+
32+
/**
33+
* https://developer.apple.com/library/archive/documentation/PassKit/Reference/PassKit_WebService/WebService.html
34+
*/
35+
@RestController
36+
@RequestMapping("/api/pass/event/{eventName}/v1")
37+
@Log4j2
38+
@RequiredArgsConstructor
39+
public class PassKitApiController {
40+
41+
private final PassKitManager passKitManager;
42+
43+
44+
@GetMapping("/version/passes/{passTypeIdentifier}/{serialNumber}")
45+
public void getLatestVersion(@PathVariable("eventName") String eventName,
46+
@PathVariable("passTypeIdentifier") String passTypeIdentifier,
47+
@PathVariable("serialNumber") String serialNumber,
48+
@RequestHeader("Authorization") String authorization,
49+
HttpServletResponse response) throws IOException {
50+
Optional<Pair<EventAndOrganizationId, Ticket>> validationResult = passKitManager.validateToken(eventName, passTypeIdentifier, serialNumber, authorization);
51+
if(validationResult.isEmpty()) {
52+
response.sendError(HttpServletResponse.SC_NOT_FOUND);
53+
} else {
54+
Pair<EventAndOrganizationId, Ticket> pair = validationResult.get();
55+
try {
56+
response.setContentType("application/vnd.apple.pkpass");
57+
passKitManager.writePass(pair.getRight(), pair.getLeft(), response.getOutputStream());
58+
} catch (Exception e) {
59+
log.warn("Error during pass generation", e);
60+
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
61+
}
62+
}
63+
}
64+
65+
66+
// not (yet) implemented APIs. These are no-op for now.
67+
68+
@GetMapping("/devices/*/registrations/*")
69+
public ResponseEntity<Void> getRegisteredPasses() {
70+
log.trace("getRegisteredPasses called. Returning 204");
71+
return ResponseEntity.noContent().build();
72+
}
73+
74+
@PostMapping("/devices/*/registrations/*/*")
75+
public ResponseEntity<Void> register() {
76+
log.trace("register called. Returning 200");
77+
return ResponseEntity.ok().build();
78+
}
79+
80+
@DeleteMapping("/devices/*/registrations/*/*")
81+
public ResponseEntity<Void> deleteRegistration() {
82+
log.trace("deleteRegistration called. Returning 200");
83+
return ResponseEntity.ok().build();
84+
}
85+
86+
@PostMapping("/log")
87+
public ResponseEntity<Void> log() {
88+
log.trace("log called. Returning 200");
89+
return ResponseEntity.ok().build();
90+
}
91+
92+
}

src/main/java/alfio/manager/NotificationManager.java

+2-2
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ public NotificationManager(Mailer mailer,
9393
TemplateManager templateManager,
9494
TicketReservationRepository ticketReservationRepository,
9595
TicketCategoryRepository ticketCategoryRepository,
96-
PassBookManager passBookManager,
96+
PassKitManager passKitManager,
9797
TicketRepository ticketRepository,
9898
TicketFieldRepository ticketFieldRepository,
9999
AdditionalServiceItemRepository additionalServiceItemRepository,
@@ -117,7 +117,7 @@ public NotificationManager(Mailer mailer,
117117
payload -> TemplateProcessor.buildInvoicePdf(payload.getLeft(), fileUploadManager, payload.getMiddle(), templateManager, payload.getRight(), extensionManager)));
118118
attachmentTransformer.put(Mailer.AttachmentIdentifier.CREDIT_NOTE_PDF, receiptOrInvoiceFactory(eventRepository,
119119
payload -> TemplateProcessor.buildCreditNotePdf(payload.getLeft(), fileUploadManager, payload.getMiddle(), templateManager, payload.getRight(), extensionManager)));
120-
attachmentTransformer.put(Mailer.AttachmentIdentifier.PASSBOOK, passBookManager::getPassBook);
120+
attachmentTransformer.put(Mailer.AttachmentIdentifier.PASSBOOK, passKitManager::getPass);
121121
Function<Ticket, List<TicketFieldConfigurationDescriptionAndValue>> retrieveFieldValues = EventUtil.retrieveFieldValues(ticketRepository, ticketFieldRepository, additionalServiceItemRepository);
122122
attachmentTransformer.put(Mailer.AttachmentIdentifier.TICKET_PDF, generateTicketPDF(eventRepository, organizationRepository, configurationManager, fileUploadManager, templateManager, ticketReservationRepository, retrieveFieldValues, extensionManager));
123123
}

src/main/java/alfio/manager/PassBookManager.java src/main/java/alfio/manager/PassKitManager.java

+100-44
Original file line numberDiff line numberDiff line change
@@ -18,54 +18,51 @@
1818

1919
import alfio.manager.system.ConfigurationManager;
2020
import alfio.model.Event;
21+
import alfio.model.EventAndOrganizationId;
2122
import alfio.model.EventDescription;
2223
import alfio.model.Ticket;
2324
import alfio.model.system.Configuration;
2425
import alfio.model.system.ConfigurationKeys;
2526
import alfio.model.user.Organization;
26-
import alfio.repository.EventDescriptionRepository;
27-
import alfio.repository.EventRepository;
28-
import alfio.repository.TicketCategoryRepository;
27+
import alfio.repository.*;
2928
import alfio.repository.user.OrganizationRepository;
3029
import alfio.util.Json;
3130
import com.github.benmanes.caffeine.cache.Cache;
3231
import com.github.benmanes.caffeine.cache.Caffeine;
3332
import com.ryantenney.passkit4j.Pass;
3433
import com.ryantenney.passkit4j.PassResource;
3534
import com.ryantenney.passkit4j.PassSerializer;
36-
import com.ryantenney.passkit4j.model.Color;
37-
import com.ryantenney.passkit4j.model.TextField;
3835
import com.ryantenney.passkit4j.model.*;
3936
import com.ryantenney.passkit4j.sign.PassSigner;
4037
import com.ryantenney.passkit4j.sign.PassSignerImpl;
4138
import com.ryantenney.passkit4j.sign.PassSigningException;
4239
import lombok.AllArgsConstructor;
4340
import lombok.extern.log4j.Log4j2;
41+
import org.apache.commons.lang3.StringUtils;
42+
import org.apache.commons.lang3.tuple.Pair;
4443
import org.imgscalr.Scalr;
4544
import org.springframework.core.io.ClassPathResource;
4645
import org.springframework.stereotype.Component;
4746

4847
import javax.imageio.ImageIO;
4948
import java.awt.image.BufferedImage;
50-
import java.io.ByteArrayInputStream;
51-
import java.io.ByteArrayOutputStream;
52-
import java.io.IOException;
53-
import java.io.InputStream;
49+
import java.io.*;
5450
import java.time.format.DateTimeFormatter;
5551
import java.time.format.FormatStyle;
56-
import java.util.List;
5752
import java.util.*;
5853
import java.util.concurrent.TimeUnit;
5954
import java.util.function.Function;
55+
import java.util.stream.Collectors;
6056

6157
import static alfio.model.system.ConfigurationKeys.*;
6258

6359
@Component
6460
@AllArgsConstructor
6561
@Log4j2
66-
class PassBookManager {
62+
public class PassKitManager {
6763

68-
private final Cache<String, Optional<byte[]>> passbookLogoCache = Caffeine.newBuilder()
64+
private static final String APPLE_PASS = "ApplePass";
65+
private final Cache<String, Optional<byte[]>> passKitLogoCache = Caffeine.newBuilder()
6966
.maximumSize(20)
7067
.expireAfterWrite(20, TimeUnit.MINUTES)
7168
.build();
@@ -75,55 +72,80 @@ class PassBookManager {
7572
private final FileUploadManager fileUploadManager;
7673
private final EventDescriptionRepository eventDescriptionRepository;
7774
private final TicketCategoryRepository ticketCategoryRepository;
75+
private final TicketRepository ticketRepository;
76+
private final TicketReservationRepository ticketReservationRepository;
7877

7978

80-
byte[] getPassBook(Map<String, String> model) {
79+
public boolean writePass(Ticket ticket, EventAndOrganizationId event, OutputStream out) throws IOException, PassSigningException {
80+
Organization organization = organizationRepository.getById(event.getOrganizationId());
81+
Map<ConfigurationKeys, String> passConf = getConfigurationKeys(event);
82+
if(!passConf.isEmpty()) {
83+
buildPass(ticket, eventRepository.findById(event.getId()), organization, passConf, out);
84+
return true;
85+
} else {
86+
log.trace("Cannot generate Pass. Missing configuration keys, check if all 5 are presents");
87+
return false;
88+
}
89+
}
90+
91+
byte[] getPass(Map<String, String> model) {
8192
try {
8293
Ticket ticket = Json.fromJson(model.get("ticket"), Ticket.class);
8394
int eventId = ticket.getEventId();
8495
Event event = eventRepository.findById(eventId);
8596
Organization organization = organizationRepository.getById(Integer.valueOf(model.get("organizationId"), 10));
8697

87-
Function<ConfigurationKeys, Configuration.ConfigurationPathKey> partial = Configuration.from(event);
88-
Map<ConfigurationKeys, Optional<String>> pbookConf = configurationManager.getStringConfigValueFrom(
89-
partial.apply(PASSBOOK_TYPE_IDENTIFIER),
90-
partial.apply(PASSBOOK_KEYSTORE),
91-
partial.apply(PASSBOOK_KEYSTORE_PASSWORD),
92-
partial.apply(PASSBOOK_TEAM_IDENTIFIER),
93-
partial.apply(PASSBOOK_PRIVATE_KEY_ALIAS));
98+
Map<ConfigurationKeys, String> passConf = getConfigurationKeys(event);
9499
//check if all are set
95-
if(pbookConf.values().stream().anyMatch(Optional::isEmpty)) {
100+
if(passConf.isEmpty()) {
96101
log.trace("Cannot generate Passbook. Missing configuration keys, check if all 5 are presents");
97102
return null;
98103
}
99104

100-
//
101-
String teamIdentifier = pbookConf.get(PASSBOOK_TEAM_IDENTIFIER).orElseThrow();
102-
String typeIdentifier = pbookConf.get(PASSBOOK_TYPE_IDENTIFIER).orElseThrow();
103-
byte[] keystoreRaw = Base64.getDecoder().decode(pbookConf.get(PASSBOOK_KEYSTORE).orElseThrow());
104-
String keystorePwd = pbookConf.get(PASSBOOK_KEYSTORE_PASSWORD).orElseThrow();
105-
String privateKeyAlias = pbookConf.get(PASSBOOK_PRIVATE_KEY_ALIAS).orElseThrow();
106-
107-
return buildPass(ticket, event, organization, teamIdentifier, typeIdentifier, keystorePwd, privateKeyAlias, new ByteArrayInputStream(keystoreRaw));
105+
try (ByteArrayOutputStream out = new ByteArrayOutputStream()){
106+
buildPass(ticket, event, organization, passConf, out);
107+
return out.toByteArray();
108+
}
108109
} catch (Exception ex) {
109110
log.warn("Got Exception while generating Passbook. Please check configuration.", ex);
110111
return null;
111112
}
112113
}
113114

114-
private byte[] buildPass(Ticket ticket,
115-
Event event,
116-
Organization organization,
117-
String teamIdentifier,
118-
String typeIdentifier,
119-
String keystorePwd,
120-
String privateKeyAlias,
121-
InputStream keyStore) throws IOException, PassSigningException {
115+
private Map<ConfigurationKeys, String> getConfigurationKeys(EventAndOrganizationId event) {
116+
Function<ConfigurationKeys, Configuration.ConfigurationPathKey> partial = Configuration.from(event);
117+
var configValues = configurationManager.getStringConfigValueFrom(
118+
partial.apply(PASSBOOK_TYPE_IDENTIFIER),
119+
partial.apply(PASSBOOK_KEYSTORE),
120+
partial.apply(PASSBOOK_KEYSTORE_PASSWORD),
121+
partial.apply(PASSBOOK_TEAM_IDENTIFIER),
122+
partial.apply(PASSBOOK_PRIVATE_KEY_ALIAS)
123+
);
124+
if(configValues.values().stream().anyMatch(Optional::isEmpty)) {
125+
return Map.of();
126+
}
127+
128+
return configValues
129+
.entrySet()
130+
.stream()
131+
.collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().orElseThrow()));
132+
}
133+
134+
private void buildPass(Ticket ticket,
135+
Event event,
136+
Organization organization,
137+
Map<ConfigurationKeys, String> config,
138+
OutputStream out) throws IOException, PassSigningException {
122139

123140
// from example: https://github.com/ryantenney/passkit4j/blob/master/src/test/java/com/ryantenney/passkit4j/EventTicketExample.java
124141
// specification: https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/Creating.html#//apple_ref/doc/uid/TP40012195-CH4-SW6
125142

126143
var ticketLocale = Locale.forLanguageTag(ticket.getUserLanguage());
144+
String teamIdentifier = config.get(PASSBOOK_TEAM_IDENTIFIER);
145+
String typeIdentifier = config.get(PASSBOOK_TYPE_IDENTIFIER);
146+
byte[] keystoreRaw = Base64.getDecoder().decode(config.get(PASSBOOK_KEYSTORE));
147+
String keystorePwd = config.get(PASSBOOK_KEYSTORE_PASSWORD);
148+
String privateKeyAlias = config.get(PASSBOOK_PRIVATE_KEY_ALIAS);
127149

128150
Location loc = new Location(Double.parseDouble(event.getLatitude()), Double.parseDouble(event.getLongitude())).altitude(0D);
129151
String eventDescription = eventDescriptionRepository.findDescriptionByEventIdTypeAndLocale(event.getId(), EventDescription.EventDescriptionType.DESCRIPTION, ticket.getUserLanguage()).orElse("");
@@ -134,6 +156,8 @@ private byte[] buildPass(Ticket ticket,
134156
.groupingIdentifier(organization.getEmail())
135157
.description(event.getDisplayName())
136158
.serialNumber(ticket.getUuid())
159+
.authenticationToken(buildAuthenticationToken(ticket, event, event.getPrivateKey()))
160+
.webServiceURL(StringUtils.removeEnd(configurationManager.getRequiredValue(Configuration.getSystemConfiguration(BASE_URL)), "/") + "/api/pass/event/" + event.getShortName() +"/")
137161
.relevantDate(Date.from(event.getBegin().toInstant()))
138162
.expirationDate(Date.from(event.getEnd().toInstant()))
139163
.locations(loc)
@@ -169,7 +193,7 @@ private byte[] buildPass(Ticket ticket,
169193

170194
fileUploadManager.findMetadata(event.getFileBlobId()).ifPresent(metadata -> {
171195
if(metadata.getContentType().equals("image/png") || metadata.getContentType().equals("image/jpeg")) {
172-
Optional<byte[]> cachedLogo = passbookLogoCache.get(event.getFileBlobId(), (id) -> {
196+
Optional<byte[]> cachedLogo = passKitLogoCache.get(event.getFileBlobId(), (id) -> {
173197
ByteArrayOutputStream baos = new ByteArrayOutputStream();
174198
fileUploadManager.outputFile(event.getFileBlobId(), baos);
175199
return readAndConvertImage(baos);
@@ -181,17 +205,49 @@ private byte[] buildPass(Ticket ticket,
181205
});
182206

183207
pass.files(passResources.toArray(new PassResource[0]));
184-
185-
try(ByteArrayOutputStream baos = new ByteArrayOutputStream();
186-
InputStream appleCert = new ClassPathResource("/alfio/certificates/AppleWWDRCA.cer").getInputStream()) {
208+
try(InputStream appleCert = new ClassPathResource("/alfio/certificates/AppleWWDRCA.cer").getInputStream()) {
187209
PassSigner signer = PassSignerImpl.builder()
188-
.keystore(keyStore, keystorePwd)
210+
.keystore(new ByteArrayInputStream(keystoreRaw), keystorePwd)
189211
.alias(privateKeyAlias)
190212
.intermediateCertificate(appleCert)
191213
.build();
192-
PassSerializer.writePkPassArchive(pass, signer, baos);
193-
return baos.toByteArray();
214+
PassSerializer.writePkPassArchive(pass, signer, out);
215+
}
216+
}
217+
218+
private String buildAuthenticationToken(Ticket ticket, EventAndOrganizationId event, String privateKey) {
219+
var code = event.getId() + "/" + ticket.getTicketsReservationId() + "/" + ticket.getUuid();
220+
return Ticket.hmacSHA256Base64(privateKey, code);
221+
}
222+
223+
public Optional<Pair<EventAndOrganizationId, Ticket>> validateToken(String eventName, String typeIdentifier, String ticketUuid, String authorizationHeader) {
224+
String token;
225+
if(authorizationHeader.startsWith(APPLE_PASS)) {
226+
// From the specs:
227+
// The Authorization header is supplied; its value is the word ApplePass, followed by a space,
228+
// followed by the pass’s authorization token as specified in the pass.
229+
token = authorizationHeader.substring(APPLE_PASS.length() + 1);
230+
} else {
231+
log.trace("Authorization Header does not start with ApplePass");
232+
return Optional.empty();
233+
}
234+
235+
var eventOptional = eventRepository.findOptionalEventAndOrganizationIdByShortName(eventName);
236+
if(eventOptional.isEmpty()) {
237+
log.trace("event {} not found", eventName);
238+
return Optional.empty();
239+
}
240+
241+
var event = eventOptional.get();
242+
var typeIdentifierOptional = configurationManager.getStringConfigValue(Configuration.from(event, PASSBOOK_TYPE_IDENTIFIER));
243+
if(typeIdentifierOptional.isEmpty() || !typeIdentifierOptional.get().equals(typeIdentifier)) {
244+
log.trace("typeIdentifier does not match. Expected {}, got {}", typeIdentifierOptional.orElse("not-found"), typeIdentifier);
245+
return Optional.empty();
194246
}
247+
return ticketRepository.findOptionalByUUID(ticketUuid)
248+
.filter(t -> t.getEventId() == event.getId())
249+
.filter(t -> buildAuthenticationToken(t, event, eventRepository.getPrivateKey(event.getId())).equals(token))
250+
.map(t -> Pair.of(event, t));
195251
}
196252

197253
private void addLogoResources(byte[] logo, List<PassResource> passResources) {

src/main/java/alfio/model/Ticket.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ public boolean isCheckedIn() {
130130
return status == TicketStatus.CHECKED_IN;
131131
}
132132

133-
private static String hmacSHA256Base64(String key, String code) {
133+
public static String hmacSHA256Base64(String key, String code) {
134134
return Base64.getEncoder().encodeToString(new HmacUtils(HmacAlgorithms.HMAC_SHA_256, key).hmac(code));
135135
}
136136

src/main/java/alfio/repository/EventRepository.java

+3
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ default ZoneId getZoneIdByEventId(int eventId) {
6565
@Query("select * from event where short_name = :eventName")
6666
Optional<Event> findOptionalByShortName(@Bind("eventName") String eventName);
6767

68+
@Query("select private_key from event where id = :eventId")
69+
String getPrivateKey(@Bind("eventId") int eventId);
70+
6871
@Query("select id, org_id from event where short_name = :eventName")
6972
Optional<EventAndOrganizationId> findOptionalEventAndOrganizationIdByShortName(@Bind("eventName") String eventName);
7073

0 commit comments

Comments
 (0)