Cookie-based validation in CronetDataSource

Using cookie validation from streamer, streamer can enforce that only clients who have the cookie are able to stream the video.

-------------
Created by MOE: https://github.com/google/moe
MOE_MIGRATED_REVID=171999924
This commit is contained in:
byungh 2017-10-12 13:03:05 -07:00 committed by Oliver Woodman
parent b71effb7b0
commit 3ae4143be3
2 changed files with 168 additions and 11 deletions

View file

@ -18,6 +18,7 @@ package com.google.android.exoplayer2.ext.cronet;
import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail; import static org.junit.Assert.fail;
import static org.mockito.Matchers.any; import static org.mockito.Matchers.any;
@ -45,6 +46,7 @@ import com.google.android.exoplayer2.upstream.TransferListener;
import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.util.Clock;
import com.google.android.exoplayer2.util.Predicate; import com.google.android.exoplayer2.util.Predicate;
import java.io.IOException; import java.io.IOException;
import java.net.CookieManager;
import java.net.SocketTimeoutException; import java.net.SocketTimeoutException;
import java.net.UnknownHostException; import java.net.UnknownHostException;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
@ -102,12 +104,14 @@ public final class CronetDataSourceTest {
@Mock private CronetEngine mockCronetEngine; @Mock private CronetEngine mockCronetEngine;
private CronetDataSource dataSourceUnderTest; private CronetDataSource dataSourceUnderTest;
private boolean redirectCalled;
@Before @Before
public void setUp() throws Exception { public void setUp() throws Exception {
System.setProperty("dexmaker.dexcache", System.setProperty("dexmaker.dexcache",
InstrumentationRegistry.getTargetContext().getCacheDir().getPath()); InstrumentationRegistry.getTargetContext().getCacheDir().getPath());
initMocks(this); initMocks(this);
CookieManager cookieManager = new CookieManager();
dataSourceUnderTest = spy( dataSourceUnderTest = spy(
new CronetDataSource( new CronetDataSource(
mockCronetEngine, mockCronetEngine,
@ -118,7 +122,8 @@ public final class CronetDataSourceTest {
TEST_READ_TIMEOUT_MS, TEST_READ_TIMEOUT_MS,
true, // resetTimeoutOnRedirects true, // resetTimeoutOnRedirects
mockClock, mockClock,
null)); null,
cookieManager));
when(mockContentTypePredicate.evaluate(anyString())).thenReturn(true); when(mockContentTypePredicate.evaluate(anyString())).thenReturn(true);
when(mockCronetEngine.newUrlRequestBuilder( when(mockCronetEngine.newUrlRequestBuilder(
anyString(), any(UrlRequest.Callback.class), any(Executor.class))) anyString(), any(UrlRequest.Callback.class), any(Executor.class)))
@ -138,10 +143,14 @@ public final class CronetDataSourceTest {
} }
private UrlResponseInfo createUrlResponseInfo(int statusCode) { private UrlResponseInfo createUrlResponseInfo(int statusCode) {
return createUrlResponseInfoWithUrl(TEST_URL, statusCode);
}
private UrlResponseInfo createUrlResponseInfoWithUrl(String url, int statusCode) {
ArrayList<Map.Entry<String, String>> responseHeaderList = new ArrayList<>(); ArrayList<Map.Entry<String, String>> responseHeaderList = new ArrayList<>();
responseHeaderList.addAll(testResponseHeader.entrySet()); responseHeaderList.addAll(testResponseHeader.entrySet());
return new UrlResponseInfoImpl( return new UrlResponseInfoImpl(
Collections.singletonList(TEST_URL), Collections.singletonList(url),
statusCode, statusCode,
null, // httpStatusText null, // httpStatusText
responseHeaderList, responseHeaderList,
@ -150,11 +159,11 @@ public final class CronetDataSourceTest {
null); // proxyServer null); // proxyServer
} }
@Test(expected = IllegalStateException.class) @Test
public void testOpeningTwiceThrows() throws HttpDataSourceException { public void testOpeningTwiceThrows() throws HttpDataSourceException {
mockResponseStartSuccess(); mockResponseStartSuccess();
dataSourceUnderTest.open(testDataSpec); dataSourceUnderTest.open(testDataSpec);
dataSourceUnderTest.open(testDataSpec); assertThrows(IllegalStateException.class, () -> dataSourceUnderTest.open(testDataSpec));
} }
@Test @Test
@ -649,6 +658,27 @@ public final class CronetDataSourceTest {
assertEquals(1, openExceptions.get()); assertEquals(1, openExceptions.get());
} }
@Test
public void testRedirectParseAndAttachCookie() throws HttpDataSourceException {
mockSingleRedirectSuccess();
testResponseHeader.put("Set-Cookie", "testcookie=testcookie; Path=/video");
dataSourceUnderTest.open(testDataSpec);
verify(mockUrlRequestBuilder).addHeader(eq("Cookie"), any(String.class));
verify(mockUrlRequest, never()).followRedirect();
verify(mockUrlRequest, times(2)).start();
}
@Test
public void testRedirectNoSetCookieFollowsRedirect() throws HttpDataSourceException {
mockSingleRedirectSuccess();
mockFollowRedirectSuccess();
dataSourceUnderTest.open(testDataSpec);
verify(mockUrlRequestBuilder, never()).addHeader(eq("Cookie"), any(String.class));
verify(mockUrlRequest).followRedirect();
}
@Test @Test
public void testExceptionFromTransferListener() throws HttpDataSourceException { public void testExceptionFromTransferListener() throws HttpDataSourceException {
mockResponseStartSuccess(); mockResponseStartSuccess();
@ -731,6 +761,38 @@ public final class CronetDataSourceTest {
}).when(mockUrlRequest).start(); }).when(mockUrlRequest).start();
} }
private void mockSingleRedirectSuccess() {
doAnswer(new Answer<Object>() {
@Override
public Object answer(InvocationOnMock invocation) throws Throwable {
if (!redirectCalled) {
redirectCalled = true;
dataSourceUnderTest.onRedirectReceived(
mockUrlRequest,
createUrlResponseInfoWithUrl("http://example.com/video", 300),
"http://example.com/video/redirect");
} else {
dataSourceUnderTest.onResponseStarted(
mockUrlRequest,
testUrlResponseInfo);
}
return null;
}
}).when(mockUrlRequest).start();
}
private void mockFollowRedirectSuccess() {
doAnswer(new Answer<Object>() {
@Override
public Object answer(InvocationOnMock invocation) throws Throwable {
dataSourceUnderTest.onResponseStarted(
mockUrlRequest,
testUrlResponseInfo);
return null;
}
}).when(mockUrlRequest).followRedirect();
}
private void mockResponseStartFailure() { private void mockResponseStartFailure() {
doAnswer(new Answer<Object>() { doAnswer(new Answer<Object>() {
@Override @Override

View file

@ -29,9 +29,13 @@ import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.util.Clock;
import com.google.android.exoplayer2.util.Predicate; import com.google.android.exoplayer2.util.Predicate;
import java.io.IOException; import java.io.IOException;
import java.net.CookieManager;
import java.net.SocketTimeoutException; import java.net.SocketTimeoutException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.UnknownHostException; import java.net.UnknownHostException;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Map.Entry; import java.util.Map.Entry;
@ -93,6 +97,7 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
Pattern.compile("^bytes (\\d+)-(\\d+)/(\\d+)$"); Pattern.compile("^bytes (\\d+)-(\\d+)/(\\d+)$");
// The size of read buffer passed to cronet UrlRequest.read(). // The size of read buffer passed to cronet UrlRequest.read().
private static final int READ_BUFFER_SIZE_BYTES = 32 * 1024; private static final int READ_BUFFER_SIZE_BYTES = 32 * 1024;
private static final String SET_COOKIE = "Set-Cookie";
private final CronetEngine cronetEngine; private final CronetEngine cronetEngine;
private final Executor executor; private final Executor executor;
@ -105,6 +110,7 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
private final RequestProperties requestProperties; private final RequestProperties requestProperties;
private final ConditionVariable operation; private final ConditionVariable operation;
private final Clock clock; private final Clock clock;
private final CookieManager cookieManager;
// Accessed by the calling thread only. // Accessed by the calling thread only.
private boolean opened; private boolean opened;
@ -144,7 +150,7 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
public CronetDataSource(CronetEngine cronetEngine, Executor executor, public CronetDataSource(CronetEngine cronetEngine, Executor executor,
Predicate<String> contentTypePredicate, TransferListener<? super CronetDataSource> listener) { Predicate<String> contentTypePredicate, TransferListener<? super CronetDataSource> listener) {
this(cronetEngine, executor, contentTypePredicate, listener, DEFAULT_CONNECT_TIMEOUT_MILLIS, this(cronetEngine, executor, contentTypePredicate, listener, DEFAULT_CONNECT_TIMEOUT_MILLIS,
DEFAULT_READ_TIMEOUT_MILLIS, false, null); DEFAULT_READ_TIMEOUT_MILLIS, false, null, /* cookieManager= */ null);
} }
/** /**
@ -168,13 +174,42 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
int connectTimeoutMs, int readTimeoutMs, boolean resetTimeoutOnRedirects, int connectTimeoutMs, int readTimeoutMs, boolean resetTimeoutOnRedirects,
RequestProperties defaultRequestProperties) { RequestProperties defaultRequestProperties) {
this(cronetEngine, executor, contentTypePredicate, listener, connectTimeoutMs, this(cronetEngine, executor, contentTypePredicate, listener, connectTimeoutMs,
readTimeoutMs, resetTimeoutOnRedirects, Clock.DEFAULT, defaultRequestProperties); readTimeoutMs, resetTimeoutOnRedirects, Clock.DEFAULT, defaultRequestProperties,
/* cookieManager= */ null);
}
/**
* @param cronetEngine A CronetEngine.
* @param executor The {@link java.util.concurrent.Executor} that will handle responses.
* This may be a direct executor (i.e. executes tasks on the calling thread) in order
* to avoid a thread hop from Cronet's internal network thread to the response handling
* thread. However, to avoid slowing down overall network performance, care must be taken
* to make sure response handling is a fast operation when using a direct executor.
* @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
* predicate then an {@link InvalidContentTypeException} is thrown from
* {@link #open(DataSpec)}.
* @param listener An optional listener.
* @param connectTimeoutMs The connection timeout, in milliseconds.
* @param readTimeoutMs The read timeout, in milliseconds.
* @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs.
* @param defaultRequestProperties The default request properties to be used.
* @param cookieManager An optional {@link CookieManager} to be used to handle "Set-Cookie"
* requests. If this is null, then "Set-Cookie" requests will be ignored.
*/
public CronetDataSource(CronetEngine cronetEngine, Executor executor,
Predicate<String> contentTypePredicate, TransferListener<? super CronetDataSource> listener,
int connectTimeoutMs, int readTimeoutMs, boolean resetTimeoutOnRedirects,
RequestProperties defaultRequestProperties, CookieManager cookieManager) {
this(cronetEngine, executor, contentTypePredicate, listener, connectTimeoutMs,
readTimeoutMs, resetTimeoutOnRedirects, Clock.DEFAULT, defaultRequestProperties,
cookieManager);
} }
/* package */ CronetDataSource(CronetEngine cronetEngine, Executor executor, /* package */ CronetDataSource(CronetEngine cronetEngine, Executor executor,
Predicate<String> contentTypePredicate, TransferListener<? super CronetDataSource> listener, Predicate<String> contentTypePredicate, TransferListener<? super CronetDataSource> listener,
int connectTimeoutMs, int readTimeoutMs, boolean resetTimeoutOnRedirects, Clock clock, int connectTimeoutMs, int readTimeoutMs, boolean resetTimeoutOnRedirects, Clock clock,
RequestProperties defaultRequestProperties) { RequestProperties defaultRequestProperties, CookieManager cookieManager) {
this.cronetEngine = Assertions.checkNotNull(cronetEngine); this.cronetEngine = Assertions.checkNotNull(cronetEngine);
this.executor = Assertions.checkNotNull(executor); this.executor = Assertions.checkNotNull(executor);
this.contentTypePredicate = contentTypePredicate; this.contentTypePredicate = contentTypePredicate;
@ -184,6 +219,7 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
this.resetTimeoutOnRedirects = resetTimeoutOnRedirects; this.resetTimeoutOnRedirects = resetTimeoutOnRedirects;
this.clock = Assertions.checkNotNull(clock); this.clock = Assertions.checkNotNull(clock);
this.defaultRequestProperties = defaultRequestProperties; this.defaultRequestProperties = defaultRequestProperties;
this.cookieManager = cookieManager;
requestProperties = new RequestProperties(); requestProperties = new RequestProperties();
operation = new ConditionVariable(); operation = new ConditionVariable();
} }
@ -223,7 +259,12 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
operation.close(); operation.close();
resetConnectTimeout(); resetConnectTimeout();
currentDataSpec = dataSpec; currentDataSpec = dataSpec;
currentUrlRequest = buildRequest(dataSpec);
try {
currentUrlRequest = buildRequest(dataSpec);
} catch (IOException e) {
throw new OpenException(e, dataSpec, Status.IDLE);
}
currentUrlRequest.start(); currentUrlRequest.start();
boolean requestStarted = blockUntilConnectTimeout(); boolean requestStarted = blockUntilConnectTimeout();
@ -379,7 +420,24 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
if (resetTimeoutOnRedirects) { if (resetTimeoutOnRedirects) {
resetConnectTimeout(); resetConnectTimeout();
} }
request.followRedirect();
Map<String, List<String>> headers = info.getAllHeaders();
if (cookieManager == null || isEmpty(headers.get(SET_COOKIE))) {
request.followRedirect();
} else {
currentUrlRequest.cancel();
UrlRequest.Builder requestBuilder =
cronetEngine.newUrlRequestBuilder(newLocationUrl, this, executor).allowDirectExecutor();
try {
parseCookies(info);
attachCookies(newLocationUrl, requestBuilder);
currentUrlRequest = requestBuilder.build();
currentUrlRequest.start();
} catch (IOException e) {
exception = e;
operation.open();
}
}
} }
@Override @Override
@ -387,7 +445,12 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
if (request != currentUrlRequest) { if (request != currentUrlRequest) {
return; return;
} }
responseInfo = info; try {
parseCookies(info);
responseInfo = info;
} catch (IOException e) {
exception = e;
}
operation.open(); operation.open();
} }
@ -427,7 +490,7 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
// Internal methods. // Internal methods.
private UrlRequest buildRequest(DataSpec dataSpec) throws OpenException { private UrlRequest buildRequest(DataSpec dataSpec) throws IOException {
UrlRequest.Builder requestBuilder = cronetEngine.newUrlRequestBuilder( UrlRequest.Builder requestBuilder = cronetEngine.newUrlRequestBuilder(
dataSpec.uri.toString(), this, executor).allowDirectExecutor(); dataSpec.uri.toString(), this, executor).allowDirectExecutor();
// Set the headers. // Set the headers.
@ -474,6 +537,7 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
executor); executor);
} }
} }
attachCookies(dataSpec.uri.toString(), requestBuilder);
return requestBuilder.build(); return requestBuilder.build();
} }
@ -491,6 +555,37 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
currentConnectTimeoutMs = clock.elapsedRealtime() + connectTimeoutMs; currentConnectTimeoutMs = clock.elapsedRealtime() + connectTimeoutMs;
} }
private void parseCookies(UrlResponseInfo info) throws IOException {
if (cookieManager == null) {
return;
}
try {
cookieManager.put(new URI(info.getUrl()), info.getAllHeaders());
} catch (URISyntaxException e) {
throw new IOException("Failed to parse cookies", e);
}
}
private void attachCookies(String url, UrlRequest.Builder requestBuilder) throws IOException {
if (cookieManager == null) {
return;
}
try {
for (Entry<String, List<String>> headers :
cookieManager.get(new URI(url), Collections.emptyMap()).entrySet()) {
StringBuilder cookies = new StringBuilder();
for (String cookie : headers.getValue()) {
cookies.append(cookie).append("; ");
}
if (cookies.length() > 0) {
requestBuilder.addHeader(headers.getKey(), cookies.substring(0, cookies.length() - 1));
}
}
} catch (URISyntaxException e) {
throw new IOException("Failed to attach cookies", e);
}
}
private static boolean getIsCompressed(UrlResponseInfo info) { private static boolean getIsCompressed(UrlResponseInfo info) {
for (Map.Entry<String, String> entry : info.getAllHeadersAsList()) { for (Map.Entry<String, String> entry : info.getAllHeadersAsList()) {
if (entry.getKey().equalsIgnoreCase("Content-Encoding")) { if (entry.getKey().equalsIgnoreCase("Content-Encoding")) {