Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config-templates/linux-local-dev-segue-config.properties
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ EMAIL_SIGNATURE=Isaac Physics Project
EVENT_ADMIN_EMAIL=events@isaacphysics.org
EVENT_ICAL_UID_DOMAIN=isaacphysics.org

SCHOOL_CSV_LIST_PATH=/local/data/schools_list_2025_december.csv
SCHOOL_CSV_LIST_PATH=/local/data/schools_list_2026_spring.csv

# Segue

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ private static boolean validateFilterQuery(final GameFilter gameFilter) {
* @param gameFilter Object representing the group of filters to use
* @param boardOwner The user that should be marked as the creator of the gameBoard.
* @return a gameboard if possible that satisfies the conditions provided by the parameters. Will return null if no
* questions can be provided.
* questions can be provided.
* @throws NoWildcardException when we are unable to provide you with a wildcard object.
* @throws SegueDatabaseException if there is an error contacting the database.
* @throws ContentManagerException if there is an error retrieving the content requested.
Expand Down Expand Up @@ -910,7 +910,7 @@ private GameboardDTO augmentGameboardWithQuestionAttemptInformationAndUserInform
*/
private GameboardDTO augmentGameboardWithQuestionAttemptInformation(
final GameboardDTO gameboardDTO, final Map<String, ? extends Map<String,
? extends List<? extends LightweightQuestionValidationResponse>>> questionAttemptsFromUser)
? extends List<? extends LightweightQuestionValidationResponse>>> questionAttemptsFromUser)
throws ContentManagerException {
if (null == gameboardDTO) {
return null;
Expand Down Expand Up @@ -1155,6 +1155,13 @@ public List<IsaacQuestionPageDTO> generateRandomQuestions(final GameFilter gameF

List<ContentDTO> generatedQuestions = results.getResults();
List<IsaacQuestionPageDTO> questionsToReturn = generatedQuestions.stream()
.filter(dto -> {
if (!(dto instanceof IsaacQuestionPageDTO)) {
log.warn("Skipping non-question DTO in random questions: {}", dto.getClass().getSimpleName());
return false;
}
return true;
})
.map(IsaacQuestionPageDTO.class::cast)
.filter(qp -> qp.getSupersededBy() == null || qp.getSupersededBy().isEmpty())
.collect(Collectors.toList());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
import uk.ac.cam.cl.dtg.segue.api.AuthenticationFacade;
import uk.ac.cam.cl.dtg.segue.api.AuthorisationFacade;
import uk.ac.cam.cl.dtg.segue.api.ContactFacade;
import uk.ac.cam.cl.dtg.segue.api.CorsFilter;
import uk.ac.cam.cl.dtg.segue.api.EmailFacade;
import uk.ac.cam.cl.dtg.segue.api.ExceptionSanitiser;
import uk.ac.cam.cl.dtg.segue.api.GlossaryFacade;
Expand All @@ -55,6 +56,7 @@
import uk.ac.cam.cl.dtg.segue.api.LogEventFacade;
import uk.ac.cam.cl.dtg.segue.api.NotificationFacade;
import uk.ac.cam.cl.dtg.segue.api.QuestionFacade;
import uk.ac.cam.cl.dtg.segue.api.SameSiteCookieFilter;
import uk.ac.cam.cl.dtg.segue.api.SchoolLookupServiceFacade;
import uk.ac.cam.cl.dtg.segue.api.SegueContentFacade;
import uk.ac.cam.cl.dtg.segue.api.SegueDefaultFacade;
Expand Down Expand Up @@ -127,9 +129,11 @@ public final Set<Object> getSingletons() {
this.singletons.add(injector.getInstance(QuizFacade.class));

// initialise filters
this.singletons.add(injector.getInstance(CorsFilter.class));
this.singletons.add(injector.getInstance(PerformanceMonitor.class));
this.singletons.add(injector.getInstance(SessionValidator.class));
this.singletons.add(injector.getInstance(ExceptionSanitiser.class));
this.singletons.add(injector.getInstance(SameSiteCookieFilter.class));

// initialise observers
this.singletons.add(injector.getInstance(IGroupObserver.class));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ private Response getUserAuthenticationSettings(final Long userId, final Register
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Get the SSO login redirect URL for an authentication provider.")
public final Response authenticate(@Context final HttpServletRequest request,
@Context final HttpServletResponse response,
@PathParam("provider") final String signinProvider) {

if (userManager.isRegisteredUserLoggedIn(request)) {
Expand All @@ -200,7 +201,7 @@ public final Response authenticate(@Context final HttpServletRequest request,

try {
Map<String, URI> redirectResponse = new ImmutableMap.Builder<String, URI>()
.put(REDIRECT_URL, userManager.authenticate(request, signinProvider)).build();
.put(REDIRECT_URL, userManager.authenticate(request, response, signinProvider)).build();

return Response.ok(redirectResponse).build();
} catch (IOException e) {
Expand Down Expand Up @@ -232,14 +233,15 @@ public final Response authenticate(@Context final HttpServletRequest request,
description = "Very similar to the login case, but records this is a link request not an account creation"
+ " request.")
public final Response linkExistingUserToProvider(@Context final HttpServletRequest request,
@Context final HttpServletResponse response,
@PathParam("provider") final String authProviderAsString) {
if (!this.userManager.isRegisteredUserLoggedIn(request)) {
return SegueErrorResponse.getNotLoggedInResponse();
}

try {
Map<String, URI> redirectResponse = new ImmutableMap.Builder<String, URI>()
.put(REDIRECT_URL, this.userManager.initiateLinkAccountToUserFlow(request, authProviderAsString))
.put(REDIRECT_URL, this.userManager.initiateLinkAccountToUserFlow(request, response, authProviderAsString))
.build();

return Response.ok(redirectResponse).build();
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/uk/ac/cam/cl/dtg/segue/api/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,8 @@ public static SchoolInfoStatus get(final boolean schoolIdProvided, final boolean

public static final String SEGUE_AUTH_COOKIE = "SEGUE_AUTH_COOKIE";
public static final String JSESSION_COOOKIE = "JSESSIONID";
public static final String OAUTH_STATE_COOKIE = "OAUTH_STATE";
public static final int OAUTH_STATE_COOKIE_TTL_SECONDS = 600;

public static final String DEFAULT_DATE_FORMAT = "EEE MMM dd HH:mm:ss Z yyyy";

Expand Down
85 changes: 85 additions & 0 deletions src/main/java/uk/ac/cam/cl/dtg/segue/api/CorsFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package uk.ac.cam.cl.dtg.segue.api;

import com.google.inject.Inject;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.container.ContainerResponseContext;
import jakarta.ws.rs.container.ContainerResponseFilter;
import jakarta.ws.rs.container.PreMatching;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.Provider;
import java.io.IOException;

/**
* CORS Filter for ALB migration.
* Emits CORS headers from the application instead of relying on nginx ingress annotations.
* This allows the API to work with AWS ALB which doesn't have a built-in CORS module.
*/
@Provider
@PreMatching
public class CorsFilter implements ContainerRequestFilter, ContainerResponseFilter {

private static final String DEFAULT_ALLOWED_ORIGINS = "https://*.isaaccomputerscience.org";

private final String allowedOrigins;

@Inject
public CorsFilter() {
this.allowedOrigins = DEFAULT_ALLOWED_ORIGINS;
}

@Override
public void filter(final ContainerRequestContext requestContext) throws IOException {
if (requestContext.getMethod().equalsIgnoreCase("OPTIONS")) {
requestContext.abortWith(
Response.ok()
.header("Access-Control-Allow-Origin", getAllowedOrigin(requestContext))
.header("Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS, PATCH")
.header("Access-Control-Allow-Headers",
"Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Api-Token")
.header("Access-Control-Allow-Credentials", "true")
.header("Access-Control-Max-Age", "3600")
.build());
}
}

@Override
public void filter(final ContainerRequestContext requestContext,
final ContainerResponseContext responseContext) throws IOException {
if (requestContext.getMethod().equalsIgnoreCase("OPTIONS")) {
// Request filter already set all headers via abortWith(). Running here too would duplicate them,
// which causes Safari/mobile to fail the CORS preflight check.
return;
}
String allowedOrigin = getAllowedOrigin(requestContext);
responseContext.getHeaders().add("Access-Control-Allow-Origin", allowedOrigin);
responseContext.getHeaders().add("Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS, PATCH");
responseContext.getHeaders().add("Access-Control-Allow-Headers",
"Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Api-Token");
responseContext.getHeaders().add("Access-Control-Allow-Credentials", "true");
}

/**
* Validate and return the allowed origin from the request.
* Currently, allows all requests from Isaac domains (*.isaaccomputerscience.org).
*
* @param requestContext the request context
* @return the allowed origin, or * if validation fails
*/
private String getAllowedOrigin(final ContainerRequestContext requestContext) {
String origin = requestContext.getHeaderString("Origin");

if (origin == null) {
return allowedOrigins;
}

// The allowedOrigins property can be configured as a regex or comma-separated list if needed.
if (origin.contains("isaaccomputerscience.org") || origin.contains("localhost")) {
return origin;
}

return allowedOrigins;
}
}
43 changes: 43 additions & 0 deletions src/main/java/uk/ac/cam/cl/dtg/segue/api/SameSiteCookieFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package uk.ac.cam.cl.dtg.segue.api;

import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerResponseContext;
import jakarta.ws.rs.container.ContainerResponseFilter;
import jakarta.ws.rs.ext.Provider;
import java.util.ArrayList;
import java.util.List;

@Provider
public class SameSiteCookieFilter implements ContainerResponseFilter {

private static final String SAME_SITE_NONE = "__SAME_SITE_NONE__";
private static final String SAME_SITE_LAX = "__SAME_SITE_LAX__";

@Override
public void filter(final ContainerRequestContext requestContext,
final ContainerResponseContext responseContext) {

@SuppressWarnings("unchecked")
List<String> setCookieHeaders = (List<String>) (List<?>) responseContext.getHeaders().get("Set-Cookie");
if (setCookieHeaders == null || setCookieHeaders.isEmpty()) {
return;
}

List<String> updatedHeaders = new ArrayList<>();
for (String cookieHeader : setCookieHeaders) {
String updatedHeader = addSameSiteAttribute(cookieHeader);
updatedHeaders.add(updatedHeader);
}

responseContext.getHeaders().put("Set-Cookie", (List<Object>) (List<?>) updatedHeaders);
}

private String addSameSiteAttribute(final String cookieHeader) {
if (cookieHeader.contains(SAME_SITE_NONE)) {
return cookieHeader.replace(SAME_SITE_NONE, "").trim() + "; SameSite=None";
} else if (cookieHeader.contains(SAME_SITE_LAX)) {
return cookieHeader.replace(SAME_SITE_LAX, "").trim() + "; SameSite=Lax";
}
return cookieHeader;
}
}
Loading
Loading