1
0
mirror of https://github.com/google/nomulus synced 2025-12-23 14:25:44 +00:00

Save session data directly in a cookie (#2732)

This commit is contained in:
Lai Jiang
2025-03-31 12:21:50 -04:00
committed by GitHub
parent 2d072c3844
commit 4999a72d96
10 changed files with 420 additions and 69 deletions

View File

@@ -0,0 +1,155 @@
// Copyright 2025 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.flows;
import static java.nio.charset.StandardCharsets.US_ASCII;
import com.google.common.base.Joiner;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableSet;
import com.google.common.flogger.FluentLogger;
import com.google.common.io.BaseEncoding;
import google.registry.request.Response;
import jakarta.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* A metadata class that saves the data directly in cookies.
*
* <p>Unlike {@link HttpSessionMetadata}, this class does not rely on a session manager to translate
* an opaque session cookie into the metadata. This means that the locality of the session manager
* is irrelevant and as long as the client (the proxy) respects the {@code Set-Cookie} headers and
* sets the respective cookies in subsequent requests in a session, the metadata will be available
* to all servers, not just the one that created the session.
*
* <p>The string representation of the metadata is saved in Base64 URL-safe format in a cookie named
* {@code SESSION_INFO}.
*/
public class CookieSessionMetadata extends SessionMetadata {
protected static final String COOKIE_NAME = "SESSION_INFO";
protected static final String REGISTRAR_ID = "clientId";
protected static final String SERVICE_EXTENSIONS = "serviceExtensionUris";
protected static final String FAILED_LOGIN_ATTEMPTS = "failedLoginAttempts";
private static final Pattern COOKIE_PATTERN = Pattern.compile("SESSION_INFO=([^;\\s]+)?");
private static final Pattern REGISTRAR_ID_PATTERN = Pattern.compile("clientId=([^,\\s]+)?");
private static final Pattern SERVICE_EXTENSIONS_PATTERN =
Pattern.compile("serviceExtensionUris=([^,\\s}]+)?");
private static final Pattern FAILED_LOGIN_ATTEMPTS_PATTERN =
Pattern.compile("failedLoginAttempts=([^,\\s]+)?");
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private final Map<String, String> data = new HashMap<>();
public CookieSessionMetadata(HttpServletRequest request) {
Optional.ofNullable(request.getHeader("Cookie"))
.ifPresent(
cookie -> {
Matcher matcher = COOKIE_PATTERN.matcher(cookie);
if (matcher.find()) {
String sessionInfo = decode(matcher.group(1));
logger.atInfo().log("SESSION INFO: %s", sessionInfo);
matcher = REGISTRAR_ID_PATTERN.matcher(sessionInfo);
if (matcher.find()) {
String registrarId = matcher.group(1);
if (!registrarId.equals("null")) {
data.put(REGISTRAR_ID, registrarId);
}
}
matcher = SERVICE_EXTENSIONS_PATTERN.matcher(sessionInfo);
if (matcher.find()) {
String serviceExtensions = matcher.group(1);
if (serviceExtensions != null) {
data.put(SERVICE_EXTENSIONS, serviceExtensions);
}
}
matcher = FAILED_LOGIN_ATTEMPTS_PATTERN.matcher(sessionInfo);
if (matcher.find()) {
String failedLoginAttempts = matcher.group(1);
data.put(FAILED_LOGIN_ATTEMPTS, failedLoginAttempts);
}
}
});
}
@Override
public void invalidate() {
data.clear();
}
@Override
public String getRegistrarId() {
return data.getOrDefault(REGISTRAR_ID, null);
}
@Override
public Set<String> getServiceExtensionUris() {
return Optional.ofNullable(data.getOrDefault(SERVICE_EXTENSIONS, null))
.map(s -> Splitter.on('.').splitToList(s))
.map(ImmutableSet::copyOf)
.orElse(ImmutableSet.of());
}
@Override
public int getFailedLoginAttempts() {
return Optional.ofNullable(data.getOrDefault(FAILED_LOGIN_ATTEMPTS, null))
.map(Integer::parseInt)
.orElse(0);
}
@Override
public void setRegistrarId(String registrarId) {
data.put(REGISTRAR_ID, registrarId);
}
@Override
public void setServiceExtensionUris(Set<String> serviceExtensionUris) {
if (serviceExtensionUris == null || serviceExtensionUris.isEmpty()) {
data.remove(SERVICE_EXTENSIONS);
} else {
data.put(SERVICE_EXTENSIONS, Joiner.on('.').join(serviceExtensionUris));
}
}
@Override
public void incrementFailedLoginAttempts() {
data.put(FAILED_LOGIN_ATTEMPTS, String.valueOf(getFailedLoginAttempts() + 1));
}
@Override
public void resetFailedLoginAttempts() {
data.remove(FAILED_LOGIN_ATTEMPTS);
}
@Override
public void save(Response response) {
String value = encode(toString());
response.setHeader("Set-Cookie", COOKIE_NAME + "=" + value);
}
protected static String encode(String plainText) {
return BaseEncoding.base64Url().encode(plainText.getBytes(US_ASCII));
}
protected static String decode(String cipherText) {
return new String(BaseEncoding.base64Url().decode(cipherText), US_ASCII);
}
}

View File

@@ -78,6 +78,8 @@ public class EppRequestHandler {
} catch (Exception e) {
logger.atWarning().withCause(e).log("handleEppCommand general exception.");
response.setStatus(SC_BAD_REQUEST);
} finally {
sessionMetadata.save(response);
}
}
}

View File

@@ -20,7 +20,7 @@ import google.registry.request.Action.Method;
import google.registry.request.Payload;
import google.registry.request.auth.Auth;
import jakarta.inject.Inject;
import jakarta.servlet.http.HttpSession;
import jakarta.servlet.http.HttpServletRequest;
/**
* Establishes a transport for EPP+TLS over HTTP. All commands and responses are EPP XML according
@@ -35,14 +35,14 @@ public class EppTlsAction implements Runnable {
@Inject @Payload byte[] inputXmlBytes;
@Inject TlsCredentials tlsCredentials;
@Inject HttpSession session;
@Inject HttpServletRequest request;
@Inject EppRequestHandler eppRequestHandler;
@Inject EppTlsAction() {}
@Override
public void run() {
eppRequestHandler.executeEpp(
new HttpSessionMetadata(session),
new CookieSessionMetadata(request),
tlsCredentials,
EppRequestSource.TLS,
false, // This endpoint is never a dry run.

View File

@@ -14,16 +14,14 @@
package google.registry.flows;
import static com.google.common.base.MoreObjects.toStringHelper;
import static google.registry.util.CollectionUtils.nullToEmpty;
import com.google.common.base.Joiner;
import jakarta.servlet.http.HttpSession;
import java.util.Optional;
import java.util.Set;
/** A metadata class that is a wrapper around {@link HttpSession}. */
public class HttpSessionMetadata implements SessionMetadata {
public class HttpSessionMetadata extends SessionMetadata {
private static final String REGISTRAR_ID = "REGISTRAR_ID";
private static final String SERVICE_EXTENSIONS = "SERVICE_EXTENSIONS";
@@ -75,13 +73,4 @@ public class HttpSessionMetadata implements SessionMetadata {
public void resetFailedLoginAttempts() {
session.removeAttribute(FAILED_LOGIN_ATTEMPTS);
}
@Override
public String toString() {
return toStringHelper(getClass())
.add("clientId", getRegistrarId())
.add("failedLoginAttempts", getFailedLoginAttempts())
.add("serviceExtensionUris", Joiner.on('.').join(nullToEmpty(getServiceExtensionUris())))
.toString();
}
}

View File

@@ -14,29 +14,45 @@
package google.registry.flows;
import static com.google.common.base.MoreObjects.toStringHelper;
import static google.registry.util.CollectionUtils.nullToEmpty;
import com.google.common.base.Joiner;
import google.registry.request.Response;
import java.util.Set;
/** Object to allow setting and retrieving session information in flows. */
public interface SessionMetadata {
public abstract class SessionMetadata {
/**
* Invalidates the session. A new instance must be created after this for future sessions.
* Attempts to invoke methods of this class after this method has been called will throw
* {@code IllegalStateException}.
* Attempts to invoke methods of this class after this method has been called will throw {@code
* IllegalStateException}.
*/
void invalidate();
public abstract void invalidate();
String getRegistrarId();
public abstract String getRegistrarId();
Set<String> getServiceExtensionUris();
public abstract Set<String> getServiceExtensionUris();
int getFailedLoginAttempts();
public abstract int getFailedLoginAttempts();
void setRegistrarId(String registrarId);
public abstract void setRegistrarId(String registrarId);
void setServiceExtensionUris(Set<String> serviceExtensionUris);
public abstract void setServiceExtensionUris(Set<String> serviceExtensionUris);
void incrementFailedLoginAttempts();
public abstract void incrementFailedLoginAttempts();
void resetFailedLoginAttempts();
public abstract void resetFailedLoginAttempts();
@Override
public String toString() {
return toStringHelper(getClass())
.add("clientId", getRegistrarId())
.add("failedLoginAttempts", getFailedLoginAttempts())
.add("serviceExtensionUris", Joiner.on('.').join(nullToEmpty(getServiceExtensionUris())))
.toString();
}
public void save(Response response) {}
}

View File

@@ -14,16 +14,13 @@
package google.registry.flows;
import static com.google.common.base.MoreObjects.toStringHelper;
import static com.google.common.base.Preconditions.checkNotNull;
import static google.registry.util.CollectionUtils.nullToEmpty;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableSet;
import java.util.Set;
/** A read-only {@link SessionMetadata} that doesn't support login/logout. */
public class StatelessRequestSessionMetadata implements SessionMetadata {
public class StatelessRequestSessionMetadata extends SessionMetadata {
private final String registrarId;
private final ImmutableSet<String> serviceExtensionUris;
@@ -74,13 +71,6 @@ public class StatelessRequestSessionMetadata implements SessionMetadata {
throw new UnsupportedOperationException();
}
@Override
public String toString() {
return toStringHelper(getClass())
.add("clientId", getRegistrarId())
.add("failedLoginAttempts", getFailedLoginAttempts())
.add("serviceExtensionUris", Joiner.on('.').join(nullToEmpty(getServiceExtensionUris())))
.toString();
}
}

View File

@@ -0,0 +1,211 @@
// Copyright 2025 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.flows;
import static com.google.common.truth.Truth.assertThat;
import static google.registry.flows.CookieSessionMetadata.COOKIE_NAME;
import static google.registry.flows.CookieSessionMetadata.decode;
import static google.registry.flows.CookieSessionMetadata.encode;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.google.common.collect.ImmutableSet;
import google.registry.testing.FakeResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
/** Unit tests for {@link CookieSessionMetadata}. */
public class CookieSessionMetadataTest {
private HttpServletRequest request = mock(HttpServletRequest.class);
private FakeResponse response = new FakeResponse();
private CookieSessionMetadata cookieSessionMetadata = new CookieSessionMetadata(request);
@Test
void testNoCookie() {
assertThat(cookieSessionMetadata.getRegistrarId()).isNull();
assertThat(cookieSessionMetadata.getFailedLoginAttempts()).isEqualTo(0);
assertThat(cookieSessionMetadata.getServiceExtensionUris()).isEmpty();
}
@Test
void testCookieWithAllFields() {
when(request.getHeader("Cookie"))
.thenReturn(
"THIS_COOKIE=foo; SESSION_INFO="
+ encode(
"CookieSessionMetadata{clientId=test_registrar, failedLoginAttempts=5, "
+ " serviceExtensionUris=A.B.C}")
+ "; THAT_COOKIE=bar");
cookieSessionMetadata = new CookieSessionMetadata(request);
assertThat(cookieSessionMetadata.getRegistrarId()).isEqualTo("test_registrar");
assertThat(cookieSessionMetadata.getFailedLoginAttempts()).isEqualTo(5);
assertThat(cookieSessionMetadata.getServiceExtensionUris()).containsExactly("A", "B", "C");
}
@Test
void testCookieWithNullRegistrar() {
when(request.getHeader("Cookie"))
.thenReturn(
"SESSION_INFO="
+ encode(
"CookieSessionMetadata{clientId=null, failedLoginAttempts=5, "
+ " serviceExtensionUris=A.B.C}"));
cookieSessionMetadata = new CookieSessionMetadata(request);
assertThat(cookieSessionMetadata.getRegistrarId()).isNull();
assertThat(cookieSessionMetadata.getFailedLoginAttempts()).isEqualTo(5);
assertThat(cookieSessionMetadata.getServiceExtensionUris()).containsExactly("A", "B", "C");
}
@Test
void testCookieWithEmptyExtension() {
when(request.getHeader("Cookie"))
.thenReturn(
"SESSION_INFO="
+ encode(
"CookieSessionMetadata{clientId=test_registrar, failedLoginAttempts=5, "
+ " serviceExtensionUris=}"));
cookieSessionMetadata = new CookieSessionMetadata(request);
assertThat(cookieSessionMetadata.getRegistrarId()).isEqualTo("test_registrar");
assertThat(cookieSessionMetadata.getFailedLoginAttempts()).isEqualTo(5);
assertThat(cookieSessionMetadata.getServiceExtensionUris()).isEmpty();
}
@Test
void testCookieWithSingleExtension() {
when(request.getHeader("Cookie"))
.thenReturn(
"SESSION_INFO="
+ encode(
"CookieSessionMetadata{clientId=test_registrar, failedLoginAttempts=5, "
+ " serviceExtensionUris=Foo}"));
cookieSessionMetadata = new CookieSessionMetadata(request);
assertThat(cookieSessionMetadata.getRegistrarId()).isEqualTo("test_registrar");
assertThat(cookieSessionMetadata.getFailedLoginAttempts()).isEqualTo(5);
assertThat(cookieSessionMetadata.getServiceExtensionUris()).containsExactly("Foo");
}
@Test
void testIncrementFailedLoginAttempts() {
when(request.getHeader("Cookie"))
.thenReturn(
"SESSION_INFO="
+ encode(
"CookieSessionMetadata{clientId=test_registrar, failedLoginAttempts=5, "
+ " serviceExtensionUris=Foo}"));
cookieSessionMetadata = new CookieSessionMetadata(request);
cookieSessionMetadata.incrementFailedLoginAttempts();
assertThat(cookieSessionMetadata.getRegistrarId()).isEqualTo("test_registrar");
assertThat(cookieSessionMetadata.getFailedLoginAttempts()).isEqualTo(6);
assertThat(cookieSessionMetadata.getServiceExtensionUris()).containsExactly("Foo");
}
@Test
void testResetFailedLoginAttempts() {
when(request.getHeader("Cookie"))
.thenReturn(
"SESSION_INFO="
+ encode(
"CookieSessionMetadata{clientId=test_registrar, failedLoginAttempts=5, "
+ " serviceExtensionUris=Foo}"));
cookieSessionMetadata = new CookieSessionMetadata(request);
cookieSessionMetadata.resetFailedLoginAttempts();
assertThat(cookieSessionMetadata.getRegistrarId()).isEqualTo("test_registrar");
assertThat(cookieSessionMetadata.getFailedLoginAttempts()).isEqualTo(0);
assertThat(cookieSessionMetadata.getServiceExtensionUris()).containsExactly("Foo");
}
@Test
void testSetRegistrarId() {
when(request.getHeader("Cookie"))
.thenReturn(
"SESSION_INFO="
+ encode(
"CookieSessionMetadata{clientId=test_registrar, failedLoginAttempts=5, "
+ " serviceExtensionUris=Foo}"));
cookieSessionMetadata = new CookieSessionMetadata(request);
cookieSessionMetadata.setRegistrarId("new_registrar");
assertThat(cookieSessionMetadata.getRegistrarId()).isEqualTo("new_registrar");
assertThat(cookieSessionMetadata.getFailedLoginAttempts()).isEqualTo(5);
assertThat(cookieSessionMetadata.getServiceExtensionUris()).containsExactly("Foo");
}
@Test
void testSetExtensions() {
when(request.getHeader("Cookie"))
.thenReturn(
"SESSION_INFO="
+ encode(
"CookieSessionMetadata{clientId=test_registrar, failedLoginAttempts=5, "
+ " serviceExtensionUris=Foo}"));
cookieSessionMetadata = new CookieSessionMetadata(request);
cookieSessionMetadata.setServiceExtensionUris(ImmutableSet.of("Bar", "Baz"));
assertThat(cookieSessionMetadata.getRegistrarId()).isEqualTo("test_registrar");
assertThat(cookieSessionMetadata.getFailedLoginAttempts()).isEqualTo(5);
assertThat(cookieSessionMetadata.getServiceExtensionUris()).containsExactly("Bar", "Baz");
}
@Test
void testSetEmptyExtensions() {
when(request.getHeader("Cookie"))
.thenReturn(
"SESSION_INFO="
+ encode(
"CookieSessionMetadata{clientId=test_registrar, failedLoginAttempts=5, "
+ " serviceExtensionUris=Foo}"));
cookieSessionMetadata = new CookieSessionMetadata(request);
cookieSessionMetadata.setServiceExtensionUris(ImmutableSet.of());
assertThat(cookieSessionMetadata.getRegistrarId()).isEqualTo("test_registrar");
assertThat(cookieSessionMetadata.getFailedLoginAttempts()).isEqualTo(5);
assertThat(cookieSessionMetadata.getServiceExtensionUris()).isEmpty();
}
@Test
void testInvalidate() {
when(request.getHeader("Cookie"))
.thenReturn(
"SESSION_INFO="
+ encode(
"CookieSessionMetadata{clientId=test_registrar, failedLoginAttempts=5, "
+ " serviceExtensionUris=Foo}"));
cookieSessionMetadata = new CookieSessionMetadata(request);
cookieSessionMetadata.invalidate();
assertThat(cookieSessionMetadata.getRegistrarId()).isNull();
assertThat(cookieSessionMetadata.getFailedLoginAttempts()).isEqualTo(0);
assertThat(cookieSessionMetadata.getServiceExtensionUris()).isEmpty();
}
@Test
void testSave() {
cookieSessionMetadata.save(response);
String value =
decode(
response.getHeaders().get("Set-Cookie").toString().substring(COOKIE_NAME.length() + 1));
assertThat(value)
.isEqualTo(
"CookieSessionMetadata{clientId=null, failedLoginAttempts=0, serviceExtensionUris=}");
cookieSessionMetadata.setRegistrarId("new_registrar");
cookieSessionMetadata.setServiceExtensionUris(ImmutableSet.of("Bar", "Baz"));
cookieSessionMetadata.incrementFailedLoginAttempts();
cookieSessionMetadata.save(response);
value =
decode(
response.getHeaders().get("Set-Cookie").toString().substring(COOKIE_NAME.length() + 1));
assertThat(value)
.isEqualTo(
"CookieSessionMetadata{clientId=new_registrar, failedLoginAttempts=1,"
+ " serviceExtensionUris=Bar.Baz}");
}
}

View File

@@ -12,17 +12,19 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.flows;
import static com.google.common.truth.Truth.assertThat;
import static java.nio.charset.StandardCharsets.US_ASCII;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import google.registry.testing.FakeHttpSession;
import com.google.common.io.BaseEncoding;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
@@ -36,12 +38,16 @@ class EppTlsActionTest {
EppTlsAction action = new EppTlsAction();
action.inputXmlBytes = INPUT_XML_BYTES;
action.tlsCredentials = mock(TlsCredentials.class);
action.session = new FakeHttpSession();
action.session.setAttribute("REGISTRAR_ID", "ClientIdentifier");
action.request = mock(HttpServletRequest.class);
when(action.request.getHeader("Cookie"))
.thenReturn(
"SESSION_INFO="
+ BaseEncoding.base64Url().encode("clientId=ClientIdentifier".getBytes(US_ASCII)));
action.eppRequestHandler = mock(EppRequestHandler.class);
action.run();
ArgumentCaptor<SessionMetadata> captor = ArgumentCaptor.forClass(SessionMetadata.class);
verify(action.eppRequestHandler).executeEpp(
verify(action.eppRequestHandler)
.executeEpp(
captor.capture(),
same(action.tlsCredentials),
eq(EppRequestSource.TLS),

View File

@@ -62,15 +62,4 @@ do
kubectl apply -f -
done
# Restart proxies
while read line
do
parts=(${line})
echo "Updating cluster ${parts[0]} in location ${parts[1]}..."
gcloud container clusters get-credentials ${parts[0]} \
--project ${project} --location ${parts[1]}
kubectl rollout restart deployment/proxy-deployment
kubectl rollout restart deployment/proxy-deployment-canary
done < <(gcloud container clusters list --project ${project} | grep proxy-cluster)
kubectl config use-context "$current_context"

View File

@@ -42,7 +42,6 @@ import java.security.cert.CertificateException;
import java.security.cert.CertificateExpiredException;
import java.security.cert.CertificateNotYetValidException;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Stream;
@@ -272,17 +271,11 @@ class SslServerInitializerTest {
getClientHandler(
sslProvider, serverSsc.cert(), clientSsc.key(), clientSsc.cert(), "TLSv1.1", null));
ImmutableList<Integer> jdkVersion =
Arrays.stream(System.getProperty("java.version").split("\\."))
.map(Integer::parseInt)
.collect(ImmutableList.toImmutableList());
// In JDK v11.0.11 and above, TLS 1.1 is not supported anymore, in which case attempting to
// connect with TLS 1.1 results in a ClosedChannelException instead of a SSLHandShakeException.
// See https://www.oracle.com/java/technologies/javase/11-0-11-relnotes.html#JDK-8202343
Class<? extends Exception> rootCause =
sslProvider == SslProvider.JDK
&& compareSemanticVersion(jdkVersion, ImmutableList.of(11, 0, 11))
? ClosedChannelException.class
: SSLHandshakeException.class;