From c09d0d929baeaa02f3438313c7979ccf6b4b3c5a Mon Sep 17 00:00:00 2001 From: Andy Tolbert Date: Tue, 30 Jan 2024 16:41:54 -0800 Subject: [PATCH] Allow CQL client certificate authentication to work without sending an AUTHENTICATE request patch by Andy Tolbert; reviewed by Abe Ratnofsky, Dinesh Joshi, Francisco Guerrero, Jyothsna Konisa for CASSANDRA-18857 --- CHANGES.txt | 1 + .../cassandra/auth/AuthCacheService.java | 14 ++ .../apache/cassandra/auth/IAuthenticator.java | 36 +++- .../auth/MutualTlsAuthenticator.java | 17 ++ ...lTlsWithPasswordFallbackAuthenticator.java | 8 + .../cassandra/transport/SimpleClient.java | 3 +- .../transport/messages/AuthResponse.java | 24 +-- .../transport/messages/AuthUtil.java | 100 +++++++++ .../transport/messages/StartupMessage.java | 32 ++- test/conf/cassandra_ssl_test.truststore | Bin 7638 -> 8646 bytes test/conf/cassandra_ssl_test_spiffe.keystore | Bin 0 -> 2329 bytes .../apache/cassandra/auth/AuthTestUtils.java | 56 ++++- .../org/apache/cassandra/cql3/CQLTester.java | 33 ++- .../transport/AuthenticationTest.java | 121 +++++++++++ .../transport/EarlyAuthenticationTest.java | 195 ++++++++++++++++++ ...kAuthenticatorEarlyAuthenticationTest.java | 67 ++++++ .../SimpleClientSslContextFactory.java | 68 ++++++ 17 files changed, 747 insertions(+), 28 deletions(-) create mode 100644 src/java/org/apache/cassandra/transport/messages/AuthUtil.java create mode 100644 test/conf/cassandra_ssl_test_spiffe.keystore create mode 100644 test/unit/org/apache/cassandra/transport/AuthenticationTest.java create mode 100644 test/unit/org/apache/cassandra/transport/EarlyAuthenticationTest.java create mode 100644 test/unit/org/apache/cassandra/transport/MutualTlsWithPasswordFallbackAuthenticatorEarlyAuthenticationTest.java create mode 100644 test/unit/org/apache/cassandra/transport/SimpleClientSslContextFactory.java diff --git a/CHANGES.txt b/CHANGES.txt index 9c7ca4e5d9..a6107c29a8 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,5 @@ 5.1 + * Allow CQL client certificate authentication to work without sending an AUTHENTICATE request (CASSANDRA-18857) * Extend nodetool tpstats and system_views.thread_pools with detailed pool parameters (CASSANDRA-19289) * Remove dependency on Sigar in favor of OSHI (CASSANDRA-16565) * Simplify the bind marker and Term logic (CASSANDRA-18813) diff --git a/src/java/org/apache/cassandra/auth/AuthCacheService.java b/src/java/org/apache/cassandra/auth/AuthCacheService.java index f6ee02e3a0..2ebc8a9924 100644 --- a/src/java/org/apache/cassandra/auth/AuthCacheService.java +++ b/src/java/org/apache/cassandra/auth/AuthCacheService.java @@ -77,4 +77,18 @@ public class AuthCacheService Roles.init(); } } + + /** + * Invalidates all registered caches, which is useful for tests that make changes to roles, identities, cidr + * permissions and so on. + */ + @VisibleForTesting + public synchronized void invalidateCaches() + { + logger.info("Bulk invalidating {} auth cache(s)", caches.size()); + for (AuthCache cache : caches) + { + cache.invalidate(); + } + } } \ No newline at end of file diff --git a/src/java/org/apache/cassandra/auth/IAuthenticator.java b/src/java/org/apache/cassandra/auth/IAuthenticator.java index 349aaf4fc2..89c08243f2 100644 --- a/src/java/org/apache/cassandra/auth/IAuthenticator.java +++ b/src/java/org/apache/cassandra/auth/IAuthenticator.java @@ -28,11 +28,31 @@ import org.apache.cassandra.exceptions.ConfigurationException; public interface IAuthenticator { /** - * Whether or not the authenticator requires explicit login. + * Whether the authenticator requires explicit login. * If false will instantiate user with AuthenticatedUser.ANONYMOUS_USER. */ boolean requireAuthentication(); + /** + * Whether the authenticator supports 'early' authentication, meaning that it can be authenticated without + * the need to send an AUTHENTICATE request to the client after receiveing a STARTUP message from the client. + *

+ * An example use case of this would be if the client could be authenticated using certificates present + * on a TLS-encrypted connection, as is done in {@link MutualTlsAuthenticator}. In this case, an AUTHENTICATE + * message is not needed because the client can be identified by information present in its provided certificate. + *

+ * If an authenticator supports requires early authentication, this should be true; + * otherwise if a client cannot authenticate using its connection details (e.g. a certificate), an AUTHENTICATE + * will be sent to the client. This may confuse a driver implementation into thinking an authenticator is needed, + * when instead the real issue is that something is missing on the connection, such as a certificate. + *

+ * If false (default behavior), an AUTHENTICATE request will be sent to the client. + */ + default boolean supportsEarlyAuthentication() + { + return false; + } + /** * Set of resources that should be made inaccessible to users and only accessible internally. * @@ -145,5 +165,19 @@ public interface IAuthenticator * @throws AuthenticationException */ public AuthenticatedUser getAuthenticatedUser() throws AuthenticationException; + + /** + * Whether it is determined that an AUTHENTICATE message should be sent to properly negotiate. + * This is used in conjunction with {@link #supportsEarlyAuthentication()} in determining if authentication + * can be done early, for example if the client can be authenticated using client certificates when handling + * a STARTUP message. + *

+ * If true (default behavior), an AUTHENTICATE message will be sent in response to a STARTUP, + * otherwise {@link #evaluateResponse(byte[])} will be called with an empty byte array when handling STARTUP. + */ + default boolean shouldSendAuthenticateMessage() + { + return true; + } } } diff --git a/src/java/org/apache/cassandra/auth/MutualTlsAuthenticator.java b/src/java/org/apache/cassandra/auth/MutualTlsAuthenticator.java index 01ff6919fa..8c379a6dbd 100644 --- a/src/java/org/apache/cassandra/auth/MutualTlsAuthenticator.java +++ b/src/java/org/apache/cassandra/auth/MutualTlsAuthenticator.java @@ -90,6 +90,12 @@ public class MutualTlsAuthenticator implements IAuthenticator return true; } + @Override + public boolean supportsEarlyAuthentication() + { + return true; + } + @Override public Set protectedResources() { @@ -149,6 +155,12 @@ public class MutualTlsAuthenticator implements IAuthenticator return null; } + @Override + public boolean shouldSendAuthenticateMessage() + { + return false; + } + @Override public boolean isComplete() { @@ -158,6 +170,11 @@ public class MutualTlsAuthenticator implements IAuthenticator @Override public AuthenticatedUser getAuthenticatedUser() throws AuthenticationException { + if (clientCertificateChain == null || clientCertificateChain.length == 0) + { + throw new AuthenticationException("No certificate present on connection"); + } + if (!certificateValidator.isValidCertificate(clientCertificateChain)) { String message = "Invalid or not supported certificate"; diff --git a/src/java/org/apache/cassandra/auth/MutualTlsWithPasswordFallbackAuthenticator.java b/src/java/org/apache/cassandra/auth/MutualTlsWithPasswordFallbackAuthenticator.java index 4c86f6a3fe..f10b7eb1a0 100644 --- a/src/java/org/apache/cassandra/auth/MutualTlsWithPasswordFallbackAuthenticator.java +++ b/src/java/org/apache/cassandra/auth/MutualTlsWithPasswordFallbackAuthenticator.java @@ -46,13 +46,21 @@ public class MutualTlsWithPasswordFallbackAuthenticator extends PasswordAuthenti mutualTlsAuthenticator.setup(); } + @Override + public boolean supportsEarlyAuthentication() + { + return true; + } + @Override public SaslNegotiator newSaslNegotiator(InetAddress clientAddress, Certificate[] certificates) { if (certificates == null || certificates.length == 0) { + // If no certificates present, fallback to PasswordAuthentication return newSaslNegotiator(clientAddress); } + // Otherwise attempt to authenticate using the client-provided certificate. return mutualTlsAuthenticator.newSaslNegotiator(clientAddress, certificates); } diff --git a/src/java/org/apache/cassandra/transport/SimpleClient.java b/src/java/org/apache/cassandra/transport/SimpleClient.java index 0fa3a7af31..8209f5b2f3 100644 --- a/src/java/org/apache/cassandra/transport/SimpleClient.java +++ b/src/java/org/apache/cassandra/transport/SimpleClient.java @@ -416,12 +416,13 @@ public class SimpleClient implements Closeable } break; case SUPPORTED: + case ERROR: // just pass through results.add(response); break; default: throw new ProtocolException(String.format("Unexpected %s response expecting " + - "READY, AUTHENTICATE or SUPPORTED", + "READY, AUTHENTICATE, ERROR or SUPPORTED", response.header.type)); } } diff --git a/src/java/org/apache/cassandra/transport/messages/AuthResponse.java b/src/java/org/apache/cassandra/transport/messages/AuthResponse.java index 81002c8281..e24b3393a1 100644 --- a/src/java/org/apache/cassandra/transport/messages/AuthResponse.java +++ b/src/java/org/apache/cassandra/transport/messages/AuthResponse.java @@ -20,11 +20,6 @@ package org.apache.cassandra.transport.messages; import java.nio.ByteBuffer; import io.netty.buffer.ByteBuf; -import org.apache.cassandra.auth.AuthEvents; -import org.apache.cassandra.auth.AuthenticatedUser; -import org.apache.cassandra.auth.IAuthenticator; -import org.apache.cassandra.exceptions.AuthenticationException; -import org.apache.cassandra.metrics.ClientMetrics; import org.apache.cassandra.service.QueryState; import org.apache.cassandra.transport.*; @@ -71,29 +66,16 @@ public class AuthResponse extends Message.Request @Override protected Response execute(QueryState queryState, long queryStartNanoTime, boolean traceRequest) { - try + return AuthUtil.handleLogin(connection, queryState, token, (negotiationComplete, challenge) -> { - IAuthenticator.SaslNegotiator negotiator = ((ServerConnection) connection).getSaslNegotiator(queryState); - byte[] challenge = negotiator.evaluateResponse(token); - if (negotiator.isComplete()) + if (negotiationComplete) { - AuthenticatedUser user = negotiator.getAuthenticatedUser(); - queryState.getClientState().login(user); - ClientMetrics.instance.markAuthSuccess(); - AuthEvents.instance.notifyAuthSuccess(queryState); - // authentication is complete, send a ready message to the client return new AuthSuccess(challenge); } else { return new AuthChallenge(challenge); } - } - catch (AuthenticationException e) - { - ClientMetrics.instance.markAuthFailure(); - AuthEvents.instance.notifyAuthFailure(queryState, e); - return ErrorMessage.fromException(e); - } + }); } } diff --git a/src/java/org/apache/cassandra/transport/messages/AuthUtil.java b/src/java/org/apache/cassandra/transport/messages/AuthUtil.java new file mode 100644 index 0000000000..1009984eac --- /dev/null +++ b/src/java/org/apache/cassandra/transport/messages/AuthUtil.java @@ -0,0 +1,100 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.cassandra.transport.messages; + +import java.util.function.BiFunction; + +import org.apache.cassandra.auth.AuthEvents; +import org.apache.cassandra.auth.AuthenticatedUser; +import org.apache.cassandra.auth.IAuthenticator; +import org.apache.cassandra.exceptions.AuthenticationException; +import org.apache.cassandra.metrics.ClientMetrics; +import org.apache.cassandra.service.QueryState; +import org.apache.cassandra.transport.Connection; +import org.apache.cassandra.transport.Message.Response; +import org.apache.cassandra.transport.ServerConnection; + +public final class AuthUtil +{ + + /** + * Attempts to authenticate a user on the current connection by negotiating with the underlying + * {@link org.apache.cassandra.auth.IAuthenticator.SaslNegotiator} using the given state of the connection + * (e.g. client certificates), the provided query state and the provided token. + *

+ * If unsuccessful an {@link ErrorMessage} is returned and the authentication failure is recorded. + *

+ * If negotiation is complete, a successful login is recorded and the given function is called with + * true and the challenge returned from + * {@link org.apache.cassandra.auth.IAuthenticator.SaslNegotiator#evaluateResponse(byte[])}. + *

+ * If negotiation is incomplete, false and the challenge is passed to the given function. + * + * @param connection The connection to authenticate + * @param queryState The current query state + * @param token The token provided in an {@link AuthResponse} from the client (or empty + * if not handling an AuthResponse). + * @param messageToSendBasedOnNegotiation Determines what response to return on based on whether sasl negotiation + * is complete (1st parameter) and the challenege token returned from the + * negotiator (2nd parameter). + * @return the response to send back to the client. + */ + static Response handleLogin(Connection connection, QueryState queryState, byte[] token, + BiFunction messageToSendBasedOnNegotiation) + { + try + { + // client-side timeout can disconnect while sitting in auth executor queue so (client default 12s) + // discard if connection closed anyway + if (!connection.channel().isActive()) + { + throw new AuthenticationException("Auth check after connection closed"); + } + IAuthenticator.SaslNegotiator negotiator = ((ServerConnection) connection).getSaslNegotiator(queryState); + byte[] challenge = negotiator.evaluateResponse(token); + if (negotiator.isComplete()) + { + AuthenticatedUser user = negotiator.getAuthenticatedUser(); + queryState.getClientState().login(user); + ClientMetrics.instance.markAuthSuccess(); + AuthEvents.instance.notifyAuthSuccess(queryState); + // authentication is complete, complete the authentication flow. + return messageToSendBasedOnNegotiation.apply(true, challenge); + } + else + { + // authentication is incomplete, continue the authentication flow. + return messageToSendBasedOnNegotiation.apply(false, challenge); + } + } + catch (AuthenticationException e) + { + ClientMetrics.instance.markAuthFailure(); + AuthEvents.instance.notifyAuthFailure(queryState, e); + return ErrorMessage.fromException(e); + } + } + + /** + * The class must not be instantiated. + */ + private AuthUtil() + { + } +} diff --git a/src/java/org/apache/cassandra/transport/messages/StartupMessage.java b/src/java/org/apache/cassandra/transport/messages/StartupMessage.java index 37afb22868..087dfb34cd 100644 --- a/src/java/org/apache/cassandra/transport/messages/StartupMessage.java +++ b/src/java/org/apache/cassandra/transport/messages/StartupMessage.java @@ -22,6 +22,7 @@ import java.util.Map; import io.netty.buffer.ByteBuf; +import org.apache.cassandra.auth.IAuthenticator; import org.apache.cassandra.config.DatabaseDescriptor; import org.apache.cassandra.service.ClientState; import org.apache.cassandra.service.QueryState; @@ -59,6 +60,8 @@ public class StartupMessage extends Message.Request } }; + private static final byte[] EMPTY_CLIENT_RESPONSE = new byte[0]; + public final Map options; public StartupMessage(Map options) @@ -118,8 +121,35 @@ public class StartupMessage extends Message.Request clientState.setDriverVersion(options.get(DRIVER_VERSION)); } - if (DatabaseDescriptor.getAuthenticator().requireAuthentication()) + IAuthenticator authenticator = DatabaseDescriptor.getAuthenticator(); + if (authenticator.requireAuthentication()) + { + // If the authenticator supports early authentication, attempt to authenticate. + if (authenticator.supportsEarlyAuthentication()) + { + IAuthenticator.SaslNegotiator negotiator = ((ServerConnection) connection).getSaslNegotiator(state); + // If the negotiator determines that sending an authenticate message is not necessary, attempt to authenticate here, + // otherwise, send an Authenticate message to begin the traditional authentication flow. + if (!negotiator.shouldSendAuthenticateMessage()) + { + // Attempt to authenticate the user. + return AuthUtil.handleLogin(connection, state, EMPTY_CLIENT_RESPONSE, (negotiationComplete, challenge) -> + { + if (negotiationComplete) + { + // Authentication was successful, proceed. + return new ReadyMessage(); + } else + { + // It's expected that any negotiator that requires a challenge will likely not support early + // authentication, in this case we can just go through the traditional auth flow. + return new AuthenticateMessage(DatabaseDescriptor.getAuthenticator().getClass().getName()); + } + }); + } + } return new AuthenticateMessage(DatabaseDescriptor.getAuthenticator().getClass().getName()); + } else return new ReadyMessage(); } diff --git a/test/conf/cassandra_ssl_test.truststore b/test/conf/cassandra_ssl_test.truststore index 10abf12f5335223c078095c9e77f6c7657f98514..8c21d853697c0ad7c476945baa966e424965956e 100644 GIT binary patch delta 817 zcmca+eaxBX-`jt085kItfS7e7&nq5hCI+tJg3PqE)a2Bn5(Wmwp1!2JcNkbB^h^yb z85o$)8Z)C%6~uXsj14S}%nePAfQ%>!ej_7Ga|3fjBd7pg?UQG*DbzRE zyj$ezcU3*`&8{aa3cJ}?$_7Wzc=t`jaTC`}eIfgsHB+bNbFpnf&pq?#Xz;W;X2B%+x=#bDF?^Sze-!Ao<3m<1L^ zxp%CzpZO{NWLbU6j9KxARBcavvVWdGXWH+p^=oV!t?f_j@=w=$uKwVKgQ8Yg>vjh- zxt+!jawGnn5njL2^PXmq?E>4+2H$pEPTU&wxwX3E&+7SKJJ0=@EqS?Py?TfF$>76h z?kHF%D7ccRo)&_sBxao@2Gh{PaolUpze-Qe%0n`K;&K^zI2# z)>C){1q&uI2?$Q!#jaehmz-E!oS2tVl&F_jkeHm2s+V7su3ws$SrP&aZ2jDloMHo6 zU=+ymv52vVXmMmd+Hvv4fh%SoUMF+ycjoZrU`LBM<|al)2F6u|R?7}?XnA!AE=uo_ zITNlcY29F<$lpEv@W)lc5p^?;_lbrq;;b>RPqbj1XKdm$t=fH?S|)S%g#$acu`q~V zvAD2-$9;unz4$Z3dy!JF)3{MEjl}{V%`sUpo6F$Na(JC9Zc~sTzH;_G=VN&Q)yQ{F&#x bfJohAj!8?eoaU`Nuj(}zWhVE5$ delta 47 zcmX@+e9fBY-`jt085kItfS6??&nuqI&jij0h7!5XEerVzZMZt)g)QK~F zWNGsI*jcv}+km}C9uJjKSS;rgCB2CFhCyQM+m1|gOPyM8rtX2v&?WGQMtsD=_Ym;S1^;o)Yj-=tb3G*1x50TkY_2uWbh$c7B&&vMGuq%!pNYLdxK@({<0Tjv_*2lHbWFUp z`i9O9cK-F1wgly(IPD#!LeO-U$~WB>%#Dgy>&8NgiYi7Ub)D^*WKMYzKU7TcnoWBT zim2wKIim8Ejwq=&oaS@@-z9@O^yb~+#8v0Jl22^%r7I>v({knIV>Mb3@LUt*#>Z=l zgs@CKtv9iRW(nUGCt*`8OFzFX{koLV@HwvGXMY37&g9%dxnb8l39*I)lujMIjyLN8^h z&9y9twH-MmD$3rc0usn-_BKxdPz_kh10$~8K+vj=5{-!o>$Gcbh_FF;uUXG}o$6u^ zD=I&I4-v2C@C+Kr*lc|I76;yw3d_5|e?NjEoLDf+`H*7om6mxVdubV0k|ch7-lmc2 zTU;~Ve_+_Ud-84rb9K9RLT@wJeHnVkeiEC@r&GLg`RlU)S7$*RoQ07U&+D~0F$Gs| z*USPsDY6Xs?#m z_q|mMHq4^-dev;-#%vPvDW)HlE~$RrUNL%G8Kl;fQ$i!<;5Q8;xiMGH^u8y*nQqG6 za!fWY8dYsGB=U`1H8}#!RewIHRi!Dy|1KW?I1nq_Z3pwzdR5}LsFW4*p}-z-#b2W zJN=+zPV!O{dq<$B(tLU_PgS)p@&wW!P7Lp3m^;=-*f(}(^l-2>lQbsa8c?%W(oUVm!o+ zdiNqBCcehcktObwA7pPpSw60OiaZ-*a+j+y$P+iV$?JPfUx)zxgpp5_WxFq1dT^=(E+vm~qKb!YB72#YK58YrJ-~fn zsyzr=)6S@RQct+*arLPC@DnQ^TNUn9!xZv@#C2$tzK?>f3&-|H#2#Fs>`dy(S?%&8 z)*)>g7NKLS1yN6H#Q{sCu?Xy_Le5erR(3;fc06t29D7klm|@XT#<#&W`_&wIT-3Ax zJrD@m1<;@!01fi^J`@6mK%gj7BS46kADw2I#uWmC1$aO}Bt~?%VL}~YyePQi`QY=R zQRn|6eE%Tue-Om~2oezanMXj7k4g>k4ELjkWA+0QKM@oqdOXaVc+O82=S`)0pZBGB z1F{lASPeiAtAo+P?k-&SiD0pMI)Dxa`(N;XyBQ69= zSYVjh4Jw_Ut7Dgt>F`o1f?Li4N9=eNB3FHn@w>Rp%uoa`9V)eBCPV6+d`@V_Gxa?x zn8%yH&c**`eivCI+nj|mz2K6X?Gs3$y9~Cuq=@(eRRuj)_3R11hv1>23|EWvQsdlZ z&yL7M-<&(1EeeLOmW{uL=4EXTmK!GP8^5fx4^aE6G%;l&hjzMHYoaa9(wHE*Y;~e4 zO0C9}@rL&eR{+kR{oXbBm5Z_ITV(~`GrG6#?(a{pP)gV7AhdRl%IbT`n)R5^XdV2V zG3>~UuJjsR$?TrgMH};#i4_FgDwz7*;Si_yBBjgfj{y3O1NyfGk;t%HqDTl73<5v! z0NernUAa<7m?nTt1O79QkEyHw6t717%d?vIFY#)jlmPXJ^MT_4BYW zY%fd<%@;WN=bNdf0qvz(BBBw;XUhlwyMf(rx@%y!D3g4wxP=dGosMJ#T#@W_R^6|k zq$?*v&u;z3M7bp7v|kfv)Z>69`iE^UE z7tGk11G527s&r!9QKuX8W2~ZiE9wpmgz@3(IzN45Go%vdD!&Y5d^vcfIq{}+v5!)! z+r|7$QimA(2fo0$`t1VR{Png^nIkB0(b+fJ_g+R!rqxR;5+T_mTtu6OC(~WDj-5>X zL*?CXE%6(%rBkXGM#vtR6!gm5){=Vs7HlE8gq_gBpV*`sEESrBBVh_Zde)A}1irjf zzjl3^E7I4+4$+xtEixOOQNXV1+a>QMlH^kI(#XSZwxL|Vgh!qlVJgIBUCzS40UGb` Ay#N3J literal 0 HcmV?d00001 diff --git a/test/unit/org/apache/cassandra/auth/AuthTestUtils.java b/test/unit/org/apache/cassandra/auth/AuthTestUtils.java index 610832ffb4..d2e40bd956 100644 --- a/test/unit/org/apache/cassandra/auth/AuthTestUtils.java +++ b/test/unit/org/apache/cassandra/auth/AuthTestUtils.java @@ -33,6 +33,8 @@ import java.util.Collection; import java.util.concurrent.Callable; import java.util.concurrent.TimeoutException; +import com.google.common.base.Charsets; + import org.apache.cassandra.auth.jmx.AuthorizationProxy; import org.apache.cassandra.config.DatabaseDescriptor; import org.apache.cassandra.cql3.CIDR; @@ -239,6 +241,27 @@ public class AuthTestUtils } } + public static class LocalMutualTlsWithPasswordFallbackAuthenticator extends MutualTlsWithPasswordFallbackAuthenticator + { + + public LocalMutualTlsWithPasswordFallbackAuthenticator(Map parameters) + { + super(parameters); + } + + @Override + ResultMessage.Rows select(SelectStatement statement, QueryOptions options) + { + return statement.executeLocally(QueryState.forInternalCalls(), options); + } + + @Override + UntypedResultSet process(String query, ConsistencyLevel cl) + { + return QueryProcessor.executeInternal(query); + } + } + public static class NoAuthSetupAuthorizationProxy extends AuthorizationProxy { public NoAuthSetupAuthorizationProxy() @@ -363,9 +386,38 @@ public class AuthTestUtils } public static void initializeIdentityRolesTable(final String identity) throws IOException, TimeoutException + { + truncateIdentityRolesTable(); + addIdentityToRole(identity, "readonly_user"); + } + + public static void truncateIdentityRolesTable() throws IOException, TimeoutException { StorageService.instance.truncate(SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.IDENTITY_TO_ROLES); - String insertQuery = "Insert into %s.%s (identity, role) values ('%s', 'readonly_user');"; - QueryProcessor.process(String.format(insertQuery, SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.IDENTITY_TO_ROLES, identity), ConsistencyLevel.ONE); + } + + public static void addIdentityToRole(final String identity, final String role) + { + String insertQuery = "INSERT INTO %s.%s (identity, role) VALUES ('%s', '%s');"; + QueryProcessor.process(String.format(insertQuery, SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.IDENTITY_TO_ROLES, identity, role), ConsistencyLevel.ONE); + } + + /** + * Convenience method for producing a username:password token expected by {@link PasswordAuthenticator} + * @param username user to encode + * @param password password to encode + * @return Byte array formatted as 0-byte, username, 0-byte, password + */ + public static byte[] getToken(String username, String password) + { + byte[] usernameBytes = username.getBytes(Charsets.UTF_8); + byte[] passwordBytes = password.getBytes(Charsets.UTF_8); + // Format of the token sent is 0-byte, username, 0-bytes, password + byte[] token = new byte[usernameBytes.length + passwordBytes.length + 2]; + token[0] = 0; + System.arraycopy(usernameBytes, 0, token, 1, usernameBytes.length); + token[usernameBytes.length + 1] = 0; + System.arraycopy(passwordBytes, 0, token, usernameBytes.length + 2, passwordBytes.length); + return token; } } diff --git a/test/unit/org/apache/cassandra/cql3/CQLTester.java b/test/unit/org/apache/cassandra/cql3/CQLTester.java index 24dd6724df..de68125f12 100644 --- a/test/unit/org/apache/cassandra/cql3/CQLTester.java +++ b/test/unit/org/apache/cassandra/cql3/CQLTester.java @@ -97,6 +97,7 @@ import org.apache.cassandra.Util; import org.apache.cassandra.auth.AuthCacheService; import org.apache.cassandra.auth.AuthSchemaChangeListener; import org.apache.cassandra.auth.AuthTestUtils; +import org.apache.cassandra.auth.IAuthenticator; import org.apache.cassandra.auth.IRoleManager; import org.apache.cassandra.concurrent.Stage; import org.apache.cassandra.config.CassandraRelevantProperties; @@ -518,7 +519,12 @@ public abstract class CQLTester protected static void requireAuthentication() { - DatabaseDescriptor.setAuthenticator(new AuthTestUtils.LocalPasswordAuthenticator()); + requireAuthentication(new AuthTestUtils.LocalPasswordAuthenticator()); + } + + protected static void requireAuthentication(final IAuthenticator authenticator) + { + DatabaseDescriptor.setAuthenticator(authenticator); DatabaseDescriptor.setAuthorizer(new AuthTestUtils.LocalCassandraAuthorizer()); DatabaseDescriptor.setNetworkAuthorizer(new AuthTestUtils.LocalCassandraNetworkAuthorizer()); DatabaseDescriptor.setCIDRAuthorizer(new AuthTestUtils.LocalCassandraCIDRAuthorizer()); @@ -530,6 +536,7 @@ public abstract class CQLTester public void setup() { loadRoleStatement(); + loadIdentityStatement(); QueryProcessor.executeInternal(createDefaultRoleQuery()); } }; @@ -547,6 +554,27 @@ public abstract class CQLTester AuthCacheService.initializeAndRegisterCaches(); } + /** + * Configures the server to require client encryption for CQL. Useful for tests which exercise TLS specific + * behavior. + *

+ * Note to use this appropriately, {@link #requireNetwork} should be given a server configurator configured + * with {@link Server.Builder#withTlsEncryptionPolicy(EncryptionOptions.TlsEncryptionPolicy)} using + * {@link org.apache.cassandra.config.EncryptionOptions.TlsEncryptionPolicy#ENCRYPTED}. + */ + protected static void requireNativeProtocolClientEncryption() + { + DatabaseDescriptor.updateNativeProtocolEncryptionOptions((encryptionOptions -> + encryptionOptions.withEnabled(true) + .withKeyStore("test/conf/cassandra_ssl_test.keystore") + .withKeyStorePassword("cassandra") + .withTrustStore("test/conf/cassandra_ssl_test.truststore") + .withTrustStorePassword("cassandra") + .withRequireEndpointVerification(false) + .withRequireClientAuth(EncryptionOptions.ClientAuth.OPTIONAL))); + } + + /** * Initialize Native Transport for test that need it. */ @@ -2205,7 +2233,8 @@ public abstract class CQLTester } @FunctionalInterface - public interface CheckedFunction { + public interface CheckedFunction + { void apply() throws Throwable; } diff --git a/test/unit/org/apache/cassandra/transport/AuthenticationTest.java b/test/unit/org/apache/cassandra/transport/AuthenticationTest.java new file mode 100644 index 0000000000..b770b8e37a --- /dev/null +++ b/test/unit/org/apache/cassandra/transport/AuthenticationTest.java @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.cassandra.transport; + +import java.io.IOException; +import java.util.function.Consumer; + +import com.google.common.collect.ImmutableMap; + +import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.QueryProcessor; +import org.apache.cassandra.exceptions.AuthenticationException; +import org.apache.cassandra.service.QueryState; +import org.apache.cassandra.transport.messages.AuthResponse; +import org.apache.cassandra.transport.messages.AuthSuccess; +import org.apache.cassandra.transport.messages.AuthenticateMessage; +import org.apache.cassandra.transport.messages.ErrorMessage; +import org.apache.cassandra.transport.messages.StartupMessage; + +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import static org.apache.cassandra.auth.AuthTestUtils.getToken; +import static org.apache.cassandra.transport.messages.StartupMessage.CQL_VERSION; +import static org.junit.Assert.fail; +import static org.psjava.util.AssertStatus.assertTrue; + +/** + * Verifies that {@link StartupMessage#execute(QueryState, long)} will prompt a client for authentication if an + * authenticator is configured. For this test {@link org.apache.cassandra.auth.PasswordAuthenticator} is used. + */ +public class AuthenticationTest extends CQLTester +{ + + @BeforeClass + public static void setup() + { + requireNetwork(); + requireAuthentication(); + } + + @Before + public void initNetwork() + { + reinitializeNetwork(); + } + + @Test + public void testSuccessfulAuth() + { + testAuthResponse((client) -> { + AuthResponse authResponse = new AuthResponse(getToken("cassandra", "cassandra")); + Message.Response authResponseResponse = client.execute(authResponse); + + if (!(authResponseResponse instanceof AuthSuccess)) + { + fail("Expected an AUTH_SUCCESS in response to a AUTH_RESPONSE with default credentials, got: " + authResponseResponse); + } + }); + } + + @Test + public void testUnsuccessfulAuth() + { + testAuthResponse((client) -> { + AuthResponse authResponse = new AuthResponse(getToken("cassandra", "badpw")); + Message.Response response = client.execute(authResponse, false); + + if (response instanceof ErrorMessage) + { + ErrorMessage errorMessage = (ErrorMessage) response; + assertTrue(errorMessage.error instanceof AuthenticationException, "Expected an AuthenticationException, got: " + errorMessage.error); + } + else + { + fail("Expected an ErrorMessage but got: " + response); + } + }); + } + + public void testAuthResponse(Consumer testFn) + { + SimpleClient.Builder builder = SimpleClient.builder(nativeAddr.getHostAddress(), nativePort); + try (SimpleClient client = builder.build()) + { + client.establishConnection(); + + // Send a StartupMessage + StartupMessage startup = new StartupMessage(ImmutableMap.of(CQL_VERSION, QueryProcessor.CQL_VERSION.toString())); + Message.Response startupResponse = client.execute(startup); + + if (!(startupResponse instanceof AuthenticateMessage)) + { + fail("Expected an AUTHENTICATE in response to a STARTUP, got: " + startupResponse); + } + + testFn.accept(client); + } + catch (IOException e) + { + fail("Error establishing connection"); + } + } +} diff --git a/test/unit/org/apache/cassandra/transport/EarlyAuthenticationTest.java b/test/unit/org/apache/cassandra/transport/EarlyAuthenticationTest.java new file mode 100644 index 0000000000..113865482a --- /dev/null +++ b/test/unit/org/apache/cassandra/transport/EarlyAuthenticationTest.java @@ -0,0 +1,195 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.cassandra.transport; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; + +import com.google.common.collect.ImmutableMap; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import org.apache.cassandra.auth.AuthCacheService; +import org.apache.cassandra.auth.IAuthenticator; +import org.apache.cassandra.auth.MutualTlsAuthenticator; +import org.apache.cassandra.auth.SpiffeCertificateValidator; +import org.apache.cassandra.config.EncryptionOptions; +import org.apache.cassandra.config.ParameterizedClass; +import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.QueryProcessor; +import org.apache.cassandra.exceptions.AuthenticationException; +import org.apache.cassandra.transport.messages.ErrorMessage; +import org.apache.cassandra.transport.messages.ReadyMessage; +import org.apache.cassandra.transport.messages.StartupMessage; + +import static org.apache.cassandra.auth.AuthTestUtils.addIdentityToRole; +import static org.apache.cassandra.auth.AuthTestUtils.truncateIdentityRolesTable; +import static org.apache.cassandra.transport.messages.StartupMessage.CQL_VERSION; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; +import static org.psjava.util.AssertStatus.assertTrue; + +/** + * Verifies the behavior of Cassandra given an authenticator ({@link MutualTlsAuthenticator}) returning + * {@link IAuthenticator#supportsEarlyAuthentication()} as true. + */ +public class EarlyAuthenticationTest extends CQLTester +{ + + // configures server with client encryption enabled. + static final Consumer serverConfigurator = server -> server.withTlsEncryptionPolicy(EncryptionOptions.TlsEncryptionPolicy.ENCRYPTED); + + static final Map authenticatorParams = ImmutableMap.of("validator_class_name", SpiffeCertificateValidator.class.getSimpleName()); + + // identity present in the client cert being used. + static final String spiffeIdentity = "spiffe://test.cassandra.apache.org/unitTest/mtls"; + + @BeforeClass + public static void setup() + { + setupWithAuthenticator(new MutualTlsAuthenticator(authenticatorParams)); + } + + static void setupWithAuthenticator(IAuthenticator authenticator) + { + requireNativeProtocolClientEncryption(); + requireNetwork(serverConfigurator, cluster -> { + }); + requireAuthentication(authenticator); + } + + @Before + public void initNetwork() throws IOException, TimeoutException + { + truncateIdentityRolesTable(); + AuthCacheService.instance.invalidateCaches(); + AuthCacheService.instance.warmCaches(); + reinitializeNetwork(serverConfigurator, cluster -> { + }); + } + + private EncryptionOptions clientEncryptionOptions(boolean presentClientCertificate) + { + // To regenerate: + // 1. generate keystore + // keytool -genkeypair -keystore test/conf/cassandra_ssl_test_spiffe.keystore -validity 100000 -keyalg RSA -dname "CN=Apache Cassandra, OU=ssl_test, O=Unknown, L=Unknown, ST=Unknown, C=Unknown" -keypass cassandra -storepass cassandra -alias spiffecert -ext SAN=URI:spiffe://test.cassandra.apache.org/unitTest/mtls -storetype jks + // 2. export cert + // keytool -export -alias spiffecert -file spiffecert.cer -keystore test/conf/cassandra_ssl_test_spiffe.keystore + // 3. import cert into truststore + // keytool -import -v -trustcacerts -alias spiffecert -file spiffecert.cer -keystore test/conf/cassandra_ssl_test.truststore + EncryptionOptions encryptionOptions = new EncryptionOptions() + .withEnabled(true) + .withRequireClientAuth(EncryptionOptions.ClientAuth.OPTIONAL) + .withTrustStore("test/conf/cassandra_ssl_test.truststore") + .withTrustStorePassword("cassandra") + .withSslContextFactory(new ParameterizedClass(SimpleClientSslContextFactory.class.getName())); + + if (presentClientCertificate) + { + encryptionOptions = encryptionOptions.withKeyStore("test/conf/cassandra_ssl_test_spiffe.keystore") + .withStoreType("JKS") + .withKeyStorePassword("cassandra"); + } + + return new EncryptionOptions(encryptionOptions); + } + + @Test + public void testEarlyAuthSuccess() + { + // given server is configured with a Mutual TLS Authenticator and the identity in the client's keystore is bound to cassandra. + addIdentityToRole(spiffeIdentity, "cassandra"); + + // when connecting, we expect to get a 'READY' message after sending a 'STARTUP' as MutualTlsAuthenticator + // supports early authentication and the client presented a cert with an identity that was bound to a role. + testStartupResponse(true, startupResponse -> { + if (!(startupResponse instanceof ReadyMessage)) + { + fail("Expected an READY in response to a STARTUP, got: " + startupResponse); + } + }); + } + + @Test + public void testEarlyAuthFailureLoginDisallowed() + { + // given server is configured with a Mutual TLS Authenticator and the identity in the client's keystore is bound + // to a role that is not permitted to log in. + // when connecting, we expect an 'ERROR' message. + addIdentityToRole(spiffeIdentity, "readonly_user"); + testStartupResponse(true, expectAuthenticationError(String.format("readonly_user is not permitted to log in", spiffeIdentity))); + } + + @Test + public void testEarlyAuthFailureMissingIdentity() + { + // given server is configured with a Mutual TLS Authenticator, but no identities are bound to roles. + // when connecting, we expect an 'ERROR' message. + testStartupResponse(true, expectAuthenticationError(String.format("Certificate identity '%s' not authorized", spiffeIdentity))); + } + + @Test + public void testNoClientCertificatePresented() + { + // given server is configured with a Mutual TLS Authenticator, but no client certificate is presented. + // when connecting, we expect an 'ERROR' message. + testStartupResponse(false, expectAuthenticationError("No certificate present on connection")); + } + + public void testStartupResponse(boolean presentClientCertificate, Consumer testFn) + { + SimpleClient.Builder builder = SimpleClient.builder(nativeAddr.getHostAddress(), nativePort) + .encryption(clientEncryptionOptions(presentClientCertificate)); + try (SimpleClient client = builder.build()) + { + client.establishConnection(); + + // Send a StartupMessage and pass the response to the handling function + StartupMessage startup = new StartupMessage(ImmutableMap.of(CQL_VERSION, QueryProcessor.CQL_VERSION.toString())); + Message.Response startupResponse = client.execute(startup, false); + testFn.accept(startupResponse); + } + catch (IOException e) + { + fail("Error establishing connection"); + } + } + + public Consumer expectAuthenticationError(final String expectedMessage) + { + return startupResponse -> { + if (startupResponse instanceof ErrorMessage) + { + ErrorMessage errorMessage = (ErrorMessage) startupResponse; + assertTrue(errorMessage.error instanceof AuthenticationException, "Expected an AuthenticationException, got: " + errorMessage.error); + AuthenticationException authException = (AuthenticationException) errorMessage.error; + assertEquals(expectedMessage, authException.getMessage()); + } + else + { + fail("Expected an ErrorMessage but got: " + startupResponse); + } + }; + } + +} + diff --git a/test/unit/org/apache/cassandra/transport/MutualTlsWithPasswordFallbackAuthenticatorEarlyAuthenticationTest.java b/test/unit/org/apache/cassandra/transport/MutualTlsWithPasswordFallbackAuthenticatorEarlyAuthenticationTest.java new file mode 100644 index 0000000000..993b9f8fc4 --- /dev/null +++ b/test/unit/org/apache/cassandra/transport/MutualTlsWithPasswordFallbackAuthenticatorEarlyAuthenticationTest.java @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.cassandra.transport; + + +import org.junit.BeforeClass; +import org.junit.Test; + +import org.apache.cassandra.auth.AuthTestUtils; +import org.apache.cassandra.auth.IAuthenticator; +import org.apache.cassandra.transport.messages.AuthenticateMessage; + +import static org.junit.Assert.fail; + +/** + * A variant of {@link EarlyAuthenticationTest} that configures + * {@link org.apache.cassandra.auth.MutualTlsWithPasswordFallbackAuthenticator} + * as the configured authenticator. + *

+ * This authenticator has an interesting property such that its underlying + * {@link IAuthenticator.SaslNegotiator#shouldSendAuthenticateMessage()} will only return false if a client + * certificate is present, otherwise it will return true, which should cause Cassandra to return an AUTHENTICATE + * message in response to a STARTUP request sent by a client which will provoke the normal authentication flow. + */ +public class MutualTlsWithPasswordFallbackAuthenticatorEarlyAuthenticationTest extends EarlyAuthenticationTest +{ + + @BeforeClass + public static void setup() + { + setupWithAuthenticator(new AuthTestUtils.LocalMutualTlsWithPasswordFallbackAuthenticator(authenticatorParams)); + } + + @Test + @Override + public void testNoClientCertificatePresented() + { + /* + * given server is configured with a Fallback password authenticator in that it supports early certificate + * authentication, but the sasl negotiator is determined based on the presence of client certificates + * + * When connecting without a client certificate, we expect the server to prompt us to authenticate. + */ + testStartupResponse(false, startupResponse -> { + if (!(startupResponse instanceof AuthenticateMessage)) + { + fail("Expected an AUTHENTICATE in response to a STARTUP, got: " + startupResponse); + } + }); + } +} diff --git a/test/unit/org/apache/cassandra/transport/SimpleClientSslContextFactory.java b/test/unit/org/apache/cassandra/transport/SimpleClientSslContextFactory.java new file mode 100644 index 0000000000..709cc50f70 --- /dev/null +++ b/test/unit/org/apache/cassandra/transport/SimpleClientSslContextFactory.java @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.cassandra.transport; + +import java.util.Map; +import javax.net.ssl.SSLException; + +import io.netty.handler.ssl.CipherSuiteFilter; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import org.apache.cassandra.config.EncryptionOptions; +import org.apache.cassandra.security.FileBasedSslContextFactory; + +/** + * A custom implementation of {@link FileBasedSslContextFactory} to be used by tests utilizing {@link SimpleClient}. + *

+ * Provides a subtly different implementation of {@link #createNettySslContext(EncryptionOptions.ClientAuth, SocketType, CipherSuiteFilter)} + * that only configures an {@link SslContext} for clients and most importantly only configures a key manager if an + * outbound keystore is configured, where the existing implementation always does this. This is useful for tests + * that try to create a client that uses encryption but does not provide a certificate. + */ +public class SimpleClientSslContextFactory extends FileBasedSslContextFactory +{ + + public SimpleClientSslContextFactory(Map parameters) + { + super(parameters); + } + + @Override + public SslContext createNettySslContext(EncryptionOptions.ClientAuth clientAuth, SocketType socketType, + CipherSuiteFilter cipherFilter) throws SSLException + { + SslContextBuilder builder = SslContextBuilder.forClient(); + // only provide a client certificate if keystore is present. + if (hasOutboundKeystore()) + { + builder.keyManager(buildOutboundKeyManagerFactory()); + } + + builder.sslProvider(getSslProvider()) + .protocols(getAcceptedProtocols()) + .trustManager(buildTrustManagerFactory()); + + // only set the cipher suites if the operator has explicity configured values for it; else, use the default + // for each ssl implemention (jdk or openssl) + if (cipher_suites != null && !cipher_suites.isEmpty()) + builder.ciphers(cipher_suites, cipherFilter); + + return builder.build(); + } +} \ No newline at end of file