Enable header-based auth for WebSocket connections

This commit is contained in:
Sergey Skrobotov
2023-09-25 11:28:23 -07:00
parent a263611746
commit d0fdae3df7
8 changed files with 147 additions and 85 deletions

View File

@@ -18,6 +18,7 @@ import java.util.Optional;
import org.apache.commons.lang3.StringUtils;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.BaseAccountAuthenticator;
import org.whispersystems.textsecuregcm.util.HeaderUtils;
/**
* A basic credential authentication interceptor enforces the presence of a valid username and password on every call.
@@ -39,7 +40,7 @@ public class BasicCredentialAuthenticationInterceptor implements ServerIntercept
@VisibleForTesting
static final Metadata.Key<String> BASIC_CREDENTIALS =
Metadata.Key.of("x-signal-basic-auth-credentials", Metadata.ASCII_STRING_MARSHALLER);
Metadata.Key.of("x-signal-auth", Metadata.ASCII_STRING_MARSHALLER);
private static final Metadata EMPTY_TRAILERS = new Metadata();
@@ -48,17 +49,20 @@ public class BasicCredentialAuthenticationInterceptor implements ServerIntercept
}
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(final ServerCall<ReqT, RespT> call,
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
final ServerCall<ReqT, RespT> call,
final Metadata headers,
final ServerCallHandler<ReqT, RespT> next) {
final String credentialString = headers.get(BASIC_CREDENTIALS);
final String authHeader = headers.get(BASIC_CREDENTIALS);
if (StringUtils.isNotBlank(credentialString)) {
try {
final BasicCredentials credentials = extractBasicCredentials(credentialString);
if (StringUtils.isNotBlank(authHeader)) {
final Optional<BasicCredentials> maybeCredentials = HeaderUtils.basicCredentialsFromAuthHeader(authHeader);
if (maybeCredentials.isEmpty()) {
call.close(Status.UNAUTHENTICATED.withDescription("Could not parse credentials"), EMPTY_TRAILERS);
} else {
final Optional<AuthenticatedAccount> maybeAuthenticatedAccount =
baseAccountAuthenticator.authenticate(credentials, false);
baseAccountAuthenticator.authenticate(maybeCredentials.get(), false);
if (maybeAuthenticatedAccount.isPresent()) {
final AuthenticatedAccount authenticatedAccount = maybeAuthenticatedAccount.get();
@@ -71,8 +75,6 @@ public class BasicCredentialAuthenticationInterceptor implements ServerIntercept
} else {
call.close(Status.UNAUTHENTICATED.withDescription("Credentials not accepted"), EMPTY_TRAILERS);
}
} catch (final IllegalArgumentException e) {
call.close(Status.UNAUTHENTICATED.withDescription("Could not parse credentials"), EMPTY_TRAILERS);
}
} else {
call.close(Status.UNAUTHENTICATED.withDescription("No credentials provided"), EMPTY_TRAILERS);
@@ -80,15 +82,4 @@ public class BasicCredentialAuthenticationInterceptor implements ServerIntercept
return new ServerCall.Listener<>() {};
}
@VisibleForTesting
static BasicCredentials extractBasicCredentials(final String credentials) {
if (credentials.indexOf(':') < 0) {
throw new IllegalArgumentException("Credentials do not include a username and password part");
}
final String[] pieces = credentials.split(":", 2);
return new BasicCredentials(pieces[0], pieces[1]);
}
}

View File

@@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.util;
import static java.util.Objects.requireNonNull;
import io.dropwizard.auth.basic.BasicCredentials;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Optional;
@@ -63,4 +64,38 @@ public final class HeaderUtils {
})
.filter(StringUtils::isNotBlank);
}
/**
* Parses a Base64-encoded value of the `Authorization` header
* in the form of `Basic dXNlcm5hbWU6cGFzc3dvcmQ=`.
* Note: parsing logic is copied from {@link io.dropwizard.auth.basic.BasicCredentialAuthFilter#getCredentials(String)}.
*/
public static Optional<BasicCredentials> basicCredentialsFromAuthHeader(final String authHeader) {
final int space = authHeader.indexOf(' ');
if (space <= 0) {
return Optional.empty();
}
final String method = authHeader.substring(0, space);
if (!"Basic".equalsIgnoreCase(method)) {
return Optional.empty();
}
final String decoded;
try {
decoded = new String(Base64.getDecoder().decode(authHeader.substring(space + 1)), StandardCharsets.UTF_8);
} catch (IllegalArgumentException e) {
return Optional.empty();
}
// Decoded credentials is 'username:password'
final int i = decoded.indexOf(':');
if (i <= 0) {
return Optional.empty();
}
final String username = decoded.substring(0, i);
final String password = decoded.substring(i + 1);
return Optional.of(new BasicCredentials(username, password));
}
}

View File

@@ -1,14 +1,18 @@
/*
* Copyright 2013-2021 Signal Messenger, LLC
* Copyright 2013 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.websocket;
import static org.whispersystems.textsecuregcm.util.HeaderUtils.basicCredentialsFromAuthHeader;
import com.google.common.net.HttpHeaders;
import io.dropwizard.auth.basic.BasicCredentials;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import javax.annotation.Nullable;
import org.eclipse.jetty.websocket.api.UpgradeRequest;
import org.whispersystems.textsecuregcm.auth.AccountAuthenticator;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
@@ -18,29 +22,32 @@ import org.whispersystems.websocket.auth.WebSocketAuthenticator;
public class WebSocketAccountAuthenticator implements WebSocketAuthenticator<AuthenticatedAccount> {
private static final AuthenticationResult<AuthenticatedAccount> CREDENTIALS_NOT_PRESENTED =
new AuthenticationResult<>(Optional.empty(), false);
private static final AuthenticationResult<AuthenticatedAccount> INVALID_CREDENTIALS_PRESENTED =
new AuthenticationResult<>(Optional.empty(), true);
private final AccountAuthenticator accountAuthenticator;
public WebSocketAccountAuthenticator(AccountAuthenticator accountAuthenticator) {
public WebSocketAccountAuthenticator(final AccountAuthenticator accountAuthenticator) {
this.accountAuthenticator = accountAuthenticator;
}
@Override
public AuthenticationResult<AuthenticatedAccount> authenticate(UpgradeRequest request)
public AuthenticationResult<AuthenticatedAccount> authenticate(final UpgradeRequest request)
throws AuthenticationException {
Map<String, List<String>> parameters = request.getParameterMap();
List<String> usernames = parameters.get("login");
List<String> passwords = parameters.get("password");
if (usernames == null || usernames.size() == 0 ||
passwords == null || passwords.size() == 0) {
return new AuthenticationResult<>(Optional.empty(), false);
}
BasicCredentials credentials = new BasicCredentials(usernames.get(0).replace(" ", "+"),
passwords.get(0).replace(" ", "+"));
try {
return new AuthenticationResult<>(accountAuthenticator.authenticate(credentials), true);
final AuthenticationResult<AuthenticatedAccount> authResultFromHeader =
authenticatedAccountFromHeaderAuth(request.getHeader(HttpHeaders.AUTHORIZATION));
// the logic here is that if the `Authorization` header was set for the request,
// it takes the priority and we use the result of the header-based auth
// ignoring the result of the query-based auth.
if (authResultFromHeader.credentialsPresented()) {
return authResultFromHeader;
}
return authenticatedAccountFromQueryParams(request);
} catch (final Exception e) {
// this will be handled and logged upstream
// the most likely exception is a transient error connecting to account storage
@@ -48,4 +55,26 @@ public class WebSocketAccountAuthenticator implements WebSocketAuthenticator<Aut
}
}
private AuthenticationResult<AuthenticatedAccount> authenticatedAccountFromQueryParams(final UpgradeRequest request) {
final Map<String, List<String>> parameters = request.getParameterMap();
final List<String> usernames = parameters.get("login");
final List<String> passwords = parameters.get("password");
if (usernames == null || usernames.size() == 0 ||
passwords == null || passwords.size() == 0) {
return CREDENTIALS_NOT_PRESENTED;
}
final BasicCredentials credentials = new BasicCredentials(usernames.get(0).replace(" ", "+"),
passwords.get(0).replace(" ", "+"));
return new AuthenticationResult<>(accountAuthenticator.authenticate(credentials), true);
}
private AuthenticationResult<AuthenticatedAccount> authenticatedAccountFromHeaderAuth(@Nullable final String authHeader)
throws AuthenticationException {
if (authHeader == null) {
return CREDENTIALS_NOT_PRESENTED;
}
return basicCredentialsFromAuthHeader(authHeader)
.map(credentials -> new AuthenticationResult<>(accountAuthenticator.authenticate(credentials), true))
.orElse(INVALID_CREDENTIALS_PRESENTED);
}
}