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
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
import io.sentry.Sentry;
import org.commcare.formplayer.auth.DjangoAuth;
import org.commcare.formplayer.auth.HqAuth;
import org.commcare.formplayer.auth.PublicFormSessionAuth;
import org.commcare.formplayer.beans.AuthenticatedRequestBean;
import org.commcare.formplayer.beans.SessionRequestBean;
import org.commcare.formplayer.beans.auth.HqUserDetailsBean;
import org.commcare.formplayer.objects.SerializableFormSession;
import org.commcare.formplayer.util.RequestUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.aspectj.lang.JoinPoint;
Expand All @@ -23,6 +26,7 @@
import org.commcare.formplayer.services.RestoreFactory;

import java.util.Arrays;
import java.util.Optional;

import datadog.trace.api.interceptor.MutableSpan;

Expand Down Expand Up @@ -115,7 +119,15 @@ public void closeRestoreFactory(JoinPoint joinPoint) throws Throwable {
restoreFactory.getSQLiteDB().closeConnection();
}

private HqAuth getHqAuth(String sessionToken) {
// Package-private for testing.
HqAuth getHqAuth(String sessionToken) {
// A public web apps session has no Django sessionid; authenticate its outbound HQ calls
// with the public session key instead. Gate this on the HMAC-authenticated `public` field
// from HQ's session_details response, never on the client-supplied header.
Optional<HqUserDetailsBean> userDetails = RequestUtils.getUserDetails();
if (userDetails.isPresent() && userDetails.get().isPublicSession()) {
return new PublicFormSessionAuth(userDetails.get().getAuthToken());
}
if (sessionToken != null) {
return new DjangoAuth(sessionToken);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public boolean matches(HttpServletRequest request) {
boolean hasCookie = Arrays.stream(request.getCookies()).anyMatch(
(cookie) -> Constants.POSTGRES_DJANGO_SESSION_ID.equals(cookie.getName())
);
if (!hasCookie) {
if (!hasCookie && !isPublicSessionRequest(request)) {
return false;
}
Authentication currentUser = SecurityContextHolder.getContext().getAuthentication();
Expand All @@ -49,9 +49,30 @@ public boolean matches(HttpServletRequest request) {

@Override
protected Object getPreAuthenticatedCredentials(HttpServletRequest request) {
if (isPublicSessionRequest(request)) {
return new PublicSessionCredential(
getCookieValue(request, Constants.PUBLIC_FORM_SESSION_COOKIE_NAME));
}
return getCookieValue(request, Constants.POSTGRES_DJANGO_SESSION_ID);
}

/**
* A public web apps request is signaled by the {@code CommCare-Public-Session: true} header
* (a credential-routing hint, not a trust signal) paired with the
* {@code public_form_session_key} cookie carrying the session key.
*/
private static boolean isPublicSessionRequest(HttpServletRequest request) {
if (!Constants.PUBLIC_FORM_SESSION_HEADER_VALUE.equals(
request.getHeader(Constants.PUBLIC_FORM_SESSION_HEADER))) {
return false;
}
return getCookieValue(request, Constants.PUBLIC_FORM_SESSION_COOKIE_NAME) != null;
}

private static String getCookieValue(HttpServletRequest request, String name) {
if (request.getCookies() != null) {
for (Cookie cookie : request.getCookies()) {
if (Constants.POSTGRES_DJANGO_SESSION_ID.equals(cookie.getName())) {
if (name.equals(cookie.getName())) {
return cookie.getValue();
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package org.commcare.formplayer.auth;

import org.commcare.formplayer.util.Constants;
import org.springframework.http.HttpHeaders;
import org.springframework.util.Assert;

/**
* {@link HqAuth} for a public web apps session.
*
* Emits the credential pair HQ requires to recognize a public session on its receiver/restore
* endpoints: the {@code public_form_session_key} cookie carrying the session key together with the
* {@code CommCare-Public-Session: true} header.
*/
public class PublicFormSessionAuth implements HqAuth {

private final String sessionKey;

public PublicFormSessionAuth(String sessionKey) {
Assert.hasText(sessionKey, "A public form session key is required");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good check to have, but I'd move this before we instantiate HqAuth rather than after, unless there's a specific reason it needs to live here? Also, this throws an IllegalArgumentException, which likely won't surface as an auth failure, did you confirm it's caught and mapped to the right error path so the user sees an appropriate error message rather than a generic exception?

this.sessionKey = sessionKey;
}

@Override
public HttpHeaders getAuthHeaders() {
return new HttpHeaders() {
{
add("Cookie", Constants.PUBLIC_FORM_SESSION_COOKIE_NAME + "=" + sessionKey);
add(Constants.PUBLIC_FORM_SESSION_HEADER, Constants.PUBLIC_FORM_SESSION_HEADER_VALUE);
}
};
}

@Override
public String toString() {
return "PublicFormSessionAuth";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.commcare.formplayer.auth;

import lombok.Value;

/**
* Typed credential for a public web apps session (one-time link).
*
* Wraps the value of the {@code public_form_session_key} cookie so that the
* {@link org.commcare.formplayer.services.HqUserDetailsService} can distinguish a public session
* from a regular Django session.
*/
@Value
public class PublicSessionCredential {
String sessionKey;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package org.commcare.formplayer.beans.auth;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

import java.io.Serializable;

/**
* HMAC-signed body sent to HQ's session_details endpoint for a public web apps session.
*
* Serializes to {@code {"publicSessionKey": ..., "domain": ...}}. HQ treats a request with a
* truthy {@code publicSessionKey} as a public session lookup, in contrast to
* {@link HqSessionKeyBean} which sends {@code sessionId}.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class HqPublicSessionKeyBean implements Serializable {
private String publicSessionKey;
private String domain;

public HqPublicSessionKeyBean(String domain, String publicSessionKey) {
this.domain = domain;
this.publicSessionKey = publicSessionKey;
}

public String getPublicSessionKey() {
return publicSessionKey;
}

public String getDomain() {
return domain;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.commcare.formplayer.beans.auth;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSetter;

import org.springframework.security.core.GrantedAuthority;
Expand Down Expand Up @@ -29,6 +30,11 @@ public class HqUserDetailsBean implements UserDetails {
private String[] enabledToggles;
private String[] enabledPreviews;

// HQ marks public web apps sessions with a JSON `public` field. `public` is a reserved word,
// so map it to this property. Primitive boolean so missing JSON defaults to false.
@JsonProperty("public")
private boolean publicSession;

public HqUserDetailsBean() {
}

Expand All @@ -47,6 +53,12 @@ public HqUserDetailsBean(String domain, String[] domains, String username, boole
}

public boolean isAuthorized(String domain, String username) {
if (publicSession) {
// Public web apps sessions authenticate via a single-use key that HQ has already
// validated and tied to exactly one domain. There is no real HQ account, so the
// per-session username is not a meaningful check.
return Arrays.asList(domains).contains(domain);
}
return isSuperUser || Arrays.asList(domains).contains(domain) && this.username.equals(
username);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package org.commcare.formplayer.services;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.commcare.formplayer.auth.PublicSessionCredential;
import org.commcare.formplayer.auth.UserDomainPreAuthPrincipal;
import org.commcare.formplayer.beans.auth.HqPublicSessionKeyBean;
import org.commcare.formplayer.beans.auth.HqSessionKeyBean;
import org.commcare.formplayer.beans.auth.HqUserDetailsBean;
import org.commcare.formplayer.exceptions.SessionAuthUnavailableException;
Expand Down Expand Up @@ -38,10 +40,23 @@ public class HqUserDetailsService implements AuthenticationUserDetailsService<Pr
private WebClient webClient;

public HqUserDetailsBean getUserDetails(String domain, String sessionKey) {
return requestUserDetails(domain, new HqSessionKeyBean(domain, sessionKey));
}

/**
* Look up user details for a public web apps session. Sends the session key as
* {@code publicSessionKey} rather than {@code sessionId} so HQ knows to resolves it against
* the public form session.
*/
public HqUserDetailsBean getPublicUserDetails(String domain, String publicSessionKey) {
return requestUserDetails(domain, new HqPublicSessionKeyBean(domain, publicSessionKey));
}

private HqUserDetailsBean requestUserDetails(String domain, Object requestBody) {
HttpHeaders headers = new HttpHeaders();
String data = null;
String data;
try {
data = objectMapper.writeValueAsString(new HqSessionKeyBean(domain, sessionKey));
data = objectMapper.writeValueAsString(requestBody);
headers.set("X-MAC-DIGEST", getHmac(data));
} catch (Exception e) {
throw new UserDetailsException(e);
Expand Down Expand Up @@ -73,9 +88,14 @@ private String getHmac(String data) throws Exception {
@Override
public UserDetails loadUserDetails(PreAuthenticatedAuthenticationToken token) throws UsernameNotFoundException {
final UserDomainPreAuthPrincipal principal = (UserDomainPreAuthPrincipal) token.getPrincipal();
final String sessionId = (String) token.getCredentials();
final Object credentials = token.getCredentials();
try {
HqUserDetailsBean userDetails = getUserDetails(principal.getDomain(), sessionId);
HqUserDetailsBean userDetails;
if (credentials instanceof PublicSessionCredential publicCredential) {
userDetails = getPublicUserDetails(principal.getDomain(), publicCredential.getSessionKey());
} else {
userDetails = getUserDetails(principal.getDomain(), (String) credentials);
}
if (!userDetails.isAuthorized(principal.getDomain(), principal.getUsername())) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I believe at this point userDetails already knows whether this is a public session, I'd suggested having a separate method to do the domain check, rather then insideisAuthorized. The signature entails checking whether a user is authorized to access a domain.

throw new UsernameNotFoundException("Unable to authenticate user in requested domain");
}
Expand Down
7 changes: 7 additions & 0 deletions src/main/java/org/commcare/formplayer/util/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@ public class Constants {
public static final String POSTGRES_DJANGO_SESSION_ID = "sessionid";
public static final String COMMCARE_USER_SUFFIX = "commcarehq.org";

// Public web apps sessions (one-time links). Must match HQ's
// corehq/apps/app_manager/const.py PUBLIC_FORM_SESSION_* values.
public static final String PUBLIC_FORM_SESSION_COOKIE_NAME = "public_form_session_key";
public static final String PUBLIC_FORM_SESSION_HEADER = "CommCare-Public-Session";
// HQ compares the header against this exact value (case-sensitive).
public static final String PUBLIC_FORM_SESSION_HEADER_VALUE = "true";

public static final int USER_LOCK_TIMEOUT = 21;
// 15 minutes in milliseconds
public static final int LOCK_DURATION = 60 * 15 * 1000;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package org.commcare.formplayer.aspects;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.commcare.formplayer.auth.DjangoAuth;
import org.commcare.formplayer.auth.HqAuth;
import org.commcare.formplayer.auth.PublicFormSessionAuth;
import org.commcare.formplayer.beans.auth.HqUserDetailsBean;
import org.commcare.formplayer.util.RequestUtils;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;

import java.util.Optional;

/**
* Unit tests for {@link UserRestoreAspect#getHqAuth} credential selection, in particular the
* choice between a Django session and a public web apps session.
*/
public class UserRestoreAspectTest {

private final UserRestoreAspect aspect = new UserRestoreAspect();

private HqUserDetailsBean bean(boolean isPublicSession, String authToken) {
HqUserDetailsBean bean = new HqUserDetailsBean("domain", new String[]{"domain"}, "user",
false, new String[]{}, new String[]{});
bean.setPublicSession(isPublicSession);
bean.setAuthToken(authToken);
return bean;
}

@Test
public void publicSession_usesPublicFormSessionAuthWithTheSessionKey() {
try (MockedStatic<RequestUtils> mocked = Mockito.mockStatic(RequestUtils.class)) {
mocked.when(RequestUtils::getUserDetails).thenReturn(Optional.of(bean(true, "pkey")));

HqAuth auth = aspect.getHqAuth(null);

assertTrue(auth instanceof PublicFormSessionAuth);
assertEquals("public_form_session_key=pkey", auth.getAuthHeaders().getFirst("Cookie"));
}
}

@Test
public void publicSession_winsEvenWhenASessionidCookieIsAlsoPresent() {
try (MockedStatic<RequestUtils> mocked = Mockito.mockStatic(RequestUtils.class)) {
mocked.when(RequestUtils::getUserDetails).thenReturn(Optional.of(bean(true, "pkey")));

// Both signals present: the public credential must win, matching inbound selection.
HqAuth auth = aspect.getHqAuth("sessionid-value");

assertTrue(auth instanceof PublicFormSessionAuth);
}
}

@Test
public void regularSession_usesDjangoAuth() {
try (MockedStatic<RequestUtils> mocked = Mockito.mockStatic(RequestUtils.class)) {
mocked.when(RequestUtils::getUserDetails).thenReturn(Optional.of(bean(false, null)));

HqAuth auth = aspect.getHqAuth("sessionid-value");

assertTrue(auth instanceof DjangoAuth);
}
}

@Test
public void noUserDetailsWithSessionToken_usesDjangoAuth() {
try (MockedStatic<RequestUtils> mocked = Mockito.mockStatic(RequestUtils.class)) {
mocked.when(RequestUtils::getUserDetails).thenReturn(Optional.empty());

HqAuth auth = aspect.getHqAuth("sessionid-value");

assertTrue(auth instanceof DjangoAuth);
}
}

@Test
public void noUserDetailsNoSessionToken_returnsNull() {
try (MockedStatic<RequestUtils> mocked = Mockito.mockStatic(RequestUtils.class)) {
mocked.when(RequestUtils::getUserDetails).thenReturn(Optional.empty());

// SMS requests have neither a public session nor a sessionid cookie.
assertNull(aspect.getHqAuth(null));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package org.commcare.formplayer.auth;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;

import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;

public class PublicFormSessionAuthTest {

@Test
public void getAuthHeaders_emitsPublicCookieAndHeaderOnly() {
HttpHeaders headers = new PublicFormSessionAuth("session-key-123").getAuthHeaders();

assertEquals("public_form_session_key=session-key-123", headers.getFirst("Cookie"));
assertEquals("true", headers.getFirst("CommCare-Public-Session"));

// Exactly the two public headers — no Django sessionid/Authorization leaks out.
assertEquals(2, headers.size());
assertFalse(headers.containsKey("sessionid"));
assertFalse(headers.containsKey("Authorization"));
}

@Test
public void toString_doesNotLeakTheKey() {
assertFalse(new PublicFormSessionAuth("super-secret-key").toString().contains("super-secret-key"));
}

@Test
public void constructor_rejectsMissingKey() {
assertThrows(IllegalArgumentException.class, () -> new PublicFormSessionAuth(null));
assertThrows(IllegalArgumentException.class, () -> new PublicFormSessionAuth(""));
}
}
Loading
Loading