Skip to content

Commit 9f59ce6

Browse files
committed
Adding support for extension codes.
1 parent 347dfb5 commit 9f59ce6

8 files changed

Lines changed: 278 additions & 30 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package no.java.submit.controller;
2+
3+
import io.quarkus.qute.Template;
4+
import io.quarkus.qute.TemplateInstance;
5+
import io.quarkus.security.Authenticated;
6+
import io.quarkus.security.identity.SecurityIdentity;
7+
import io.smallrye.common.annotation.Blocking;
8+
import jakarta.inject.Inject;
9+
import jakarta.inject.Named;
10+
import jakarta.ws.rs.*;
11+
import jakarta.ws.rs.core.Context;
12+
import jakarta.ws.rs.core.MediaType;
13+
import jakarta.ws.rs.core.NewCookie;
14+
import jakarta.ws.rs.core.Response;
15+
import jakarta.ws.rs.core.UriBuilder;
16+
import no.java.submit.util.CodeHelper;
17+
import no.java.submit.util.ExtensionService;
18+
import no.java.submit.util.UserHelper;
19+
import org.eclipse.microprofile.config.inject.ConfigProperty;
20+
21+
import java.time.LocalDate;
22+
import java.time.format.DateTimeParseException;
23+
import java.util.List;
24+
25+
@Path("code")
26+
@Blocking
27+
@Produces(MediaType.TEXT_HTML)
28+
@Authenticated
29+
public class CodeController {
30+
31+
@ConfigProperty(name = "app.cookie.secure", defaultValue = "true")
32+
boolean cookieSecure;
33+
34+
@Inject
35+
Template code;
36+
37+
@Inject
38+
CodeHelper codeHelper;
39+
40+
@Inject
41+
ExtensionService extensionService;
42+
43+
@Inject
44+
@Named("app.admins")
45+
List<String> appAdmins;
46+
47+
@GET
48+
public TemplateInstance view(@Context SecurityIdentity identity) {
49+
return page(identity, null, null, null);
50+
}
51+
52+
@POST
53+
@Path("use")
54+
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
55+
public Response use(@FormParam("code") String submitted, @Context SecurityIdentity identity) {
56+
var email = UserHelper.getEmail(identity);
57+
var valid = codeHelper.validate(submitted, email)
58+
.filter(date -> !date.isBefore(LocalDate.now()));
59+
60+
if (valid.isEmpty()) {
61+
return Response.ok(page(identity, "The code is not valid for your account.", null, null).render()).build();
62+
}
63+
64+
var cookie = new NewCookie.Builder(ExtensionService.COOKIE_NAME)
65+
.value(submitted)
66+
.path("/")
67+
.httpOnly(true)
68+
.secure(cookieSecure)
69+
.sameSite(NewCookie.SameSite.STRICT)
70+
.build();
71+
72+
return Response.seeOther(UriBuilder.fromUri("/").build())
73+
.cookie(cookie)
74+
.build();
75+
}
76+
77+
@POST
78+
@Path("generate")
79+
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
80+
public TemplateInstance generate(@FormParam("date") String dateStr, @FormParam("email") String rawEmail, @Context SecurityIdentity identity) {
81+
if (!appAdmins.contains(UserHelper.getEmail(identity)))
82+
throw new NotAuthorizedException("Not admin");
83+
84+
LocalDate date;
85+
try {
86+
date = LocalDate.parse(dateStr);
87+
} catch (DateTimeParseException | NullPointerException e) {
88+
return page(identity, "Invalid date (use YYYY-MM-DD).", null, null);
89+
}
90+
91+
var email = rawEmail == null ? "" : rawEmail.trim();
92+
if (email.isBlank()) {
93+
return page(identity, "Email is required.", null, null);
94+
}
95+
96+
var generated = new Generated(codeHelper.generate(date, email), date, email);
97+
return page(identity, null, null, generated);
98+
}
99+
100+
private TemplateInstance page(SecurityIdentity identity, String error, String success, Generated generated) {
101+
return code
102+
.data("valid", extensionService.validFor(identity).orElse(null))
103+
.data("error", error)
104+
.data("success", success)
105+
.data("generated", generated)
106+
.data("isAdmin", appAdmins.contains(UserHelper.getEmail(identity)));
107+
}
108+
109+
public record Generated(String code, LocalDate date, String email) {
110+
}
111+
}

src/main/java/no/java/submit/controller/TalkController.java

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import no.java.submit.service.ConferenceService;
2020
import no.java.submit.service.TalksService;
2121
import no.java.submit.service.TimelineService;
22+
import no.java.submit.util.ExtensionService;
2223
import no.java.submit.util.SessionHelper;
2324
import no.java.submit.util.SessionSecretHelper;
2425
import no.java.submit.util.UserHelper;
@@ -51,6 +52,9 @@ public class TalkController {
5152
@Inject
5253
SessionSecretHelper sessionSecrets;
5354

55+
@Inject
56+
ExtensionService extensionService;
57+
5458
@Inject
5559
Template talk;
5660

@@ -133,7 +137,7 @@ public RestResponse<?> redirectWithSecret(@PathParam("sessionId") String session
133137
@Path("new")
134138
@Authenticated
135139
public TemplateInstance newSession(@Context SecurityIdentity securityIdentity) {
136-
if (timelineService.isClosed(UserHelper.hasExtension(securityIdentity)))
140+
if (timelineService.isClosed(extensionService.has(securityIdentity)))
137141
return error
138142
.data("title", "Too late")
139143
.data("message", "It is past deadline for submission of new talks.");
@@ -157,7 +161,7 @@ public TemplateInstance newSession(@Context SecurityIdentity securityIdentity) {
157161
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
158162
@Authenticated
159163
public Object newSessionPost(SessionForm form, @Context SecurityIdentity securityIdentity) {
160-
if (timelineService.isClosed(UserHelper.hasExtension(securityIdentity)))
164+
if (timelineService.isClosed(extensionService.has(securityIdentity)))
161165
return error
162166
.data("title", "Too late")
163167
.data("message", "It is past deadline for submission of new talks.");
@@ -192,18 +196,18 @@ public Object newSessionPost(SessionForm form, @Context SecurityIdentity securit
192196
public TemplateInstance editSession(@PathParam("sessionId") String sessionId, @Context SecurityIdentity securityIdentity) {
193197
var email = UserHelper.getEmail(securityIdentity);
194198

195-
return editSession(sessionId, email, null);
199+
return editSession(sessionId, email, null, extensionService.has(securityIdentity));
196200
}
197201

198202
@GET
199203
@Path("{sessionId}/{secret}/edit")
200204
public TemplateInstance editSession(@PathParam("sessionId") String sessionId, @PathParam("secret") String secret) {
201205
sessionSecrets.validate(sessionId, secret);
202206

203-
return editSession(sessionId, "", secret);
207+
return editSession(sessionId, "", secret, false);
204208
}
205209

206-
public TemplateInstance editSession(String sessionId, String email, String secret) {
210+
public TemplateInstance editSession(String sessionId, String email, String secret, boolean hasExtension) {
207211
try {
208212
var session = talksService.getSession(email, sessionId);
209213

@@ -213,7 +217,7 @@ public TemplateInstance editSession(String sessionId, String email, String secre
213217
if (!conferenceService.current().id.equals(session.conferenceId))
214218
throw new NotFoundException();
215219

216-
return (timelineService.isClosed(appAdmins.contains(email)) ? sessionFormClosed : sessionForm)
220+
return (timelineService.isClosed(appAdmins.contains(email) || hasExtension) ? sessionFormClosed : sessionForm)
217221
.data("form", SessionForm.parse(session))
218222
.data("val", Collections.emptyMap())
219223
.data("sessionId", sessionId)
@@ -230,7 +234,7 @@ public TemplateInstance editSession(String sessionId, String email, String secre
230234
public Object editSessionPost(@PathParam("sessionId") String sessionId, SessionForm form, @Context SecurityIdentity securityIdentity) {
231235
var email = UserHelper.getEmail(securityIdentity);
232236

233-
return editSessionPost(sessionId, form, email, null);
237+
return editSessionPost(sessionId, form, email, null, extensionService.has(securityIdentity));
234238
}
235239

236240
@POST
@@ -239,14 +243,14 @@ public Object editSessionPost(@PathParam("sessionId") String sessionId, SessionF
239243
public Object editSessionPost(@PathParam("sessionId") String sessionId, @PathParam("secret") String secret, SessionForm form) {
240244
sessionSecrets.validate(sessionId, secret);
241245

242-
return editSessionPost(sessionId, form, "", secret);
246+
return editSessionPost(sessionId, form, "", secret, false);
243247
}
244248

245-
public Object editSessionPost(String sessionId, SessionForm form, String email, String secret) {
249+
public Object editSessionPost(String sessionId, SessionForm form, String email, String secret, boolean hasExtension) {
246250
// Validate form and present form if there are any errors
247251
var validation = validator.validate(form);
248252
if (!validation.isEmpty()) {
249-
return (timelineService.isClosed(appAdmins.contains(email)) ? sessionFormClosed : sessionForm)
253+
return (timelineService.isClosed(appAdmins.contains(email) || hasExtension) ? sessionFormClosed : sessionForm)
250254
.data("form", form)
251255
.data("val", validation.stream().collect(Collectors.groupingBy(c -> c.getPropertyPath().toString())))
252256
.data("sessionId", sessionId)
@@ -262,7 +266,7 @@ public Object editSessionPost(String sessionId, SessionForm form, String email,
262266
// Prepare form for sending
263267
var newSession = form.asSession();
264268

265-
if (timelineService.isClosed(appAdmins.contains(email))) {
269+
if (timelineService.isClosed(appAdmins.contains(email) || hasExtension)) {
266270
SessionHelper.partialUpdate(session, newSession);
267271
} else {
268272
// Update session with new data

src/main/java/no/java/submit/template/UserExtension.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import io.quarkus.qute.TemplateExtension;
66
import io.quarkus.security.identity.CurrentIdentityAssociation;
77
import io.quarkus.security.identity.SecurityIdentity;
8+
import no.java.submit.util.ExtensionService;
9+
import org.eclipse.microprofile.config.ConfigProvider;
810

911
// Makes user information available to templates as "user:[method]"
1012
@TemplateExtension(namespace = "user")
@@ -23,7 +25,19 @@ public static String email() {
2325
}
2426

2527
public static boolean extension() {
26-
// return ((SecurityFilter.MyPrincipal) get().getPrincipal()).hasExtension();
28+
return Arc.container().instance(ExtensionService.class).get().has(get());
29+
}
30+
31+
public static boolean isAdmin() {
32+
var identity = get();
33+
if (identity.isAnonymous())
34+
return false;
35+
36+
var admins = ConfigProvider.getConfig().getOptionalValue("app.admins", String.class).orElse("");
37+
var currentEmail = email();
38+
for (var admin : admins.split(","))
39+
if (admin.trim().equals(currentEmail))
40+
return true;
2741
return false;
2842
}
2943
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package no.java.submit.util;
2+
3+
import com.google.common.hash.Hashing;
4+
import com.google.common.io.BaseEncoding;
5+
import jakarta.enterprise.context.ApplicationScoped;
6+
import jakarta.inject.Inject;
7+
import jakarta.inject.Named;
8+
9+
import java.time.LocalDate;
10+
import java.time.format.DateTimeParseException;
11+
import java.util.Optional;
12+
13+
@ApplicationScoped
14+
public class CodeHelper {
15+
16+
@Inject
17+
@Named("app.secret")
18+
String appSecret;
19+
20+
public String generate(LocalDate date, String email) {
21+
return String.format("%s.%s", date, signature(date, email));
22+
}
23+
24+
public Optional<LocalDate> validate(String code, String email) {
25+
if (code == null || email == null || email.isBlank())
26+
return Optional.empty();
27+
28+
var parts = code.split("\\.", 2);
29+
if (parts.length != 2)
30+
return Optional.empty();
31+
32+
LocalDate date;
33+
try {
34+
date = LocalDate.parse(parts[0]);
35+
} catch (DateTimeParseException e) {
36+
return Optional.empty();
37+
}
38+
39+
return signature(date, email).equals(parts[1]) ? Optional.of(date) : Optional.empty();
40+
}
41+
42+
private String signature(LocalDate date, String email) {
43+
var str = String.format("extension:%s:%s:%s", date, email, appSecret);
44+
return BaseEncoding.base32().omitPadding().encode(Hashing.sha256().hashBytes(str.getBytes()).asBytes()).toLowerCase();
45+
}
46+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package no.java.submit.util;
2+
3+
import io.quarkus.security.identity.SecurityIdentity;
4+
import io.vertx.ext.web.RoutingContext;
5+
import jakarta.enterprise.context.RequestScoped;
6+
import jakarta.inject.Inject;
7+
8+
import java.time.LocalDate;
9+
import java.util.Optional;
10+
11+
@RequestScoped
12+
public class ExtensionService {
13+
14+
public static final String COOKIE_NAME = "extension";
15+
16+
@Inject
17+
CodeHelper codeHelper;
18+
19+
@Inject
20+
RoutingContext routingContext;
21+
22+
public Optional<LocalDate> validFor(SecurityIdentity identity) {
23+
if (identity == null || identity.isAnonymous())
24+
return Optional.empty();
25+
26+
var cookie = routingContext.request().getCookie(COOKIE_NAME);
27+
if (cookie == null)
28+
return Optional.empty();
29+
30+
return codeHelper.validate(cookie.getValue(), UserHelper.getEmail(identity))
31+
.filter(date -> !date.isBefore(LocalDate.now()));
32+
}
33+
34+
public boolean has(SecurityIdentity identity) {
35+
return validFor(identity).isPresent();
36+
}
37+
}

src/main/java/no/java/submit/util/UserHelper.java

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,4 @@ static OidcJwtCallerPrincipal getPrincipal(SecurityIdentity securityIdentity) {
1212
static String getEmail(SecurityIdentity securityIdentity) {
1313
return getPrincipal(securityIdentity).getClaim("email");
1414
}
15-
16-
static boolean hasExtension(SecurityIdentity securityIdentity) {
17-
// TODO
18-
return false;
19-
/* return getPrincipal(securityIdentity).getGroups().stream()
20-
.anyMatch(group -> group.equals("extension")); */
21-
}
2215
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{#include partial/base}
2+
<h1>Extension code</h1>
3+
4+
{#if error != null}
5+
<div class="message error">{error}</div>
6+
{/if}
7+
8+
{#if valid != null}
9+
<div class="message">Your extension is active and valid through <strong>{valid}</strong>.</div>
10+
{/if}
11+
12+
<form method="post" action="/code/use" class="talk">
13+
<h2>Use a code</h2>
14+
<p>Paste the extension code you received to regain access to submit or edit a talk after the deadline. The code is tied to your account.</p>
15+
16+
<div class="field required">
17+
<label for="form-code">Code</label>
18+
<input type="text" name="code" id="form-code" required="required" autocomplete="off"/>
19+
</div>
20+
21+
<div class="submit">
22+
<button type="submit">Activate</button>
23+
</div>
24+
</form>
25+
26+
{#if isAdmin}
27+
<form method="post" action="/code/generate" class="talk">
28+
<h2>Generate a code (admin)</h2>
29+
<p>Produce an extension code for a specific speaker. The code grants access through and including the chosen date.</p>
30+
31+
<div class="split">
32+
<div class="field required">
33+
<label for="form-date">Valid through</label>
34+
<input type="date" name="date" id="form-date" value="{generated?.date ?: ''}" required="required"/>
35+
</div>
36+
<div class="field required">
37+
<label for="form-email">Speaker email</label>
38+
<input type="email" name="email" id="form-email" value="{generated?.email ?: ''}" required="required"/>
39+
</div>
40+
</div>
41+
42+
<div class="submit">
43+
<button type="submit">Generate</button>
44+
</div>
45+
</form>
46+
47+
{#if generated != null}
48+
<div class="message">
49+
<p>Code for <strong>{generated.email}</strong>, valid through <strong>{generated.date}</strong>:</p>
50+
<pre>{generated.code}</pre>
51+
</div>
52+
{/if}
53+
{/if}
54+
{/include}

0 commit comments

Comments
 (0)