[#1155] Make the rest2ldap bind templates and HTTP Basic credentials work as documented - #1165
Conversation
maximthomas
left a comment
There was a problem hiding this comment.
praise: The bind templates and the Basic parser now fail where the issue says they should, and the new tests pin it.
Utils.formatBindDnruns beforegetConnectionAsync()in bothSimpleBindStrategy.authenticateandSaslPlainStrategy.authenticate, so a user name that does not give a DN fails the returned promise withINVALID_CREDENTIALS. At the base, the exception was thrown after the connection had been requested and beforethenFinally(close)was attached, so that connection was never closed.CredentialExtractors.parseUsernamePasswordsplits at the first:(RFC 7617) and matches the scheme withregionMatches(true, …), so the match no longer depends on the default locale.testBasicReturnNullOnInvalidCredentialsnow checks the headers it builds, not a freshnew Headers().
issue (non-blocking): A Basic header whose credentials have a legal-alphabet length that is not a multiple of 4, such as Authorization: Basic abc, still throws NullPointerException from the parser.
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/CredentialExtractors.java:127
org.forgerock.util.encode.Base64.decode drops illegal characters, then returns null when the rest is not a multiple of 4 long (probe against util-3.2.0: "abc" → null, "!!!" → empty array). new String((byte[]) null) throws, and nothing catches it on the way up from Authorization.canApplyFilter or HttpBasicAuthenticationFilter.filter, so the request ends in an unchecked exception instead of a 401 or a fall-through to the next mechanism. This affects the rest2ldap gateway and the server's HttpBasicAuthorizationMechanism. The same expression was at the base, but this method is the one the PR hardens, and the new Basic !!! row only reaches the empty-array case.
final byte[] decoded = Base64.decode(base64UserCredentials);
if (decoded == null) {
return null;
}
final String userCredentials = new String(decoded);Pin: a { "Basic abc" } row in the testBasicReturnNullOnInvalidCredentials data provider is red without the null check.
suggestion (non-blocking): For sasl-plain, dn: {username} (with a space after dn:) is not read as the whole-DN form, but the OAuth2 dn: {dn} is.
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/SaslPlainStrategy.java:72, Utils.java:80
SaslPlainStrategy keeps " %s" after the dn: key, and formatBindDn treats a template as whole-DN only when it equals "%s" exactly. DN.format then escapes a DN user name such as uid=bjensen,ou=People,dc=example,dc=com as one RDN value, and every bind gets a 401. AuthzIdTemplate.removeTemplateKey (AuthzIdTemplate.java:110) trims after the key, and AuthzIdTemplateTest pins dn: {dn}, so the same spelling works for OAuth2 and fails for sasl-plain.
final String dnTemplate = authcIdTemplate.substring("dn:".length()).trim();Pin: a SaslPlainStrategyTest case with dn: %s and a DN user name, expecting the authentication ID dn:uid=bjensen,ou=People,dc=example,dc=com.
suggestion (non-blocking): testUserNameWhichIsNotADnFailsThePromise does not pin that a user name which does not give a DN opens no connection.
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/SimpleBindStrategyTest.java:91-95
The case runs against an in-memory factory and asserts only the result code. A mutant that moves connectionFactory.getConnectionAsync() above the try { formatBindDn(...) } block still fails with INVALID_CREDENTIALS, opens a connection that is never closed (the leak at the base), and leaves all five cases green. Not run.
@Test
public void testUserNameWhichIsNotADnTakesNoConnection() throws Exception {
final ConnectionFactory connections = mock(ConnectionFactory.class);
assertFailsWith(newSimpleBindStrategy(connections, "%s", Schema.getDefaultSchema())
.authenticate("bjensen", "secret", new RootContext()), ResultCode.INVALID_CREDENTIALS);
verify(connections, never()).getConnectionAsync();
}Pin: verify(connections, never()).getConnectionAsync() fails against the hoisted-connection mutant (imports org.mockito.Mockito.mock, never, verify).
…P Basic credentials work as documented
- sasl-plain: format the part after "dn:" as a bind DN template and send
"dn:<DN>" as the authentication ID; the whole "dn:..." string used to go to
DN.format() and every bind failed with INVALID_DN_SYNTAX.
- simple (and sasl-plain "dn:"): a template which is just {username}, the
documented default, takes the user name as the bind DN instead of escaping
it as one attribute value. A user name which does not give a DN now fails
the returned promise with INVALID_CREDENTIALS (HTTP 401) instead of being
thrown from authenticate().
- OAuth2 authzIdTemplate: a "dn:" template which is just one placeholder,
such as "dn:{sub}", takes the field value as the DN as well.
- HTTP Basic: split the credentials at the first ':' (RFC 7617), so that a
password may contain ':'. Require the space after the "Basic" scheme: a bare
"Basic" header threw StringIndexOutOfBoundsException.
- Refuse an empty password with 401 before any bind: a simple bind with a DN
and no password is an unauthenticated bind (RFC 4513 section 5.1.2).
- Keep a '%' in the fixed part of DnTemplate, AuthzIdTemplate and the
{username} templates of the "basic" configuration literal, and strip a
trailing ",.." from a DnTemplate only when its comma is not escaped.
…d "dn: {username}" as "dn:{username}", pin that a user name which is not a DN takes no connection
- CredentialExtractors: Base64.decode returns null when the credentials, once the characters outside base64 are dropped, are not a multiple of 4 long ("Basic abc", or unpadded base64). The parser threw NullPointerException; it now returns null, as for any other unusable header.
- SaslPlainStrategy: spaces after "dn:" are ignored, as AuthzIdTemplate does for the OAuth2 template, so "dn: {username}" takes a DN user name as the DN instead of escaping it as one attribute value.
- SimpleBindStrategyTest, SaslPlainStrategyTest: a user name which is not a DN takes no connection from the factory.
e654c93 to
87dff9e
Compare
|
Thanks for the review. All three points are taken in 87dff9e. The branch is rebased on the current
No connection for a user name which is not a DN. Added Checks:
|
maximthomas
left a comment
There was a problem hiding this comment.
praise: The new commit closes all three round-1 items, and each one has a test that pins it.
CredentialExtractorsreturns null whenBase64.decodereturns null, soBasic abcmeans "no credentials" rather than aNullPointerException. TheinvalidBasicHeadersprovider pins it withBasic abcand an unpadded row.SimpleBindStrategyTest.testUserNameWhichIsNotADnTakesNoConnectionand theSaslPlainStrategyTestnon-DN case bothverify(..., never()).getConnectionAsync(), soUtils.formatBindDnfailing before a connection is taken is now pinned.SaslPlainStrategytrims the template afterdn:, sodn: {username}reads like the OAuth2dn: {dn}.
Problem
rest2ldap could not build an LDAP name from HTTP Basic credentials in several configurations the reference documentation recommends. See #1155.
sasl-plainwith anauthzIdTemplatestarting withdn:failed every bind withINVALID_DN_SYNTAX, because the wholedn:...string went toDN.format().simplebind with its defaultbindDnTemplate,{username}, never worked.DN.format()escaped the whole DN user name as one attribute value. The parse error was thrown fromauthenticate()instead of failing the returned promise.:was dropped: the credentials were split withsplit(":")and anything but two parts meant "no credentials". A bareBasicheader threwStringIndexOutOfBoundsException.%in the fixed part of a template was read byString.format()as a format specifier (o=100%,dc=example→MissingFormatArgumentException: '%,d').DnTemplatealso stripped a trailing,..after an escaped comma.Change
authz/Utils.formatBindDn, used bySimpleBindStrategyand thedn:branch ofSaslPlainStrategy): a template which is just{username}takes the user name as the DN (DN.valueOf); any other template keepsDN.format(), which escapes the user name as one attribute value. A user name which does not give a DN fails the returned promise withINVALID_CREDENTIALS(HTTP 401) before a connection is taken.sasl-plain: only the part afterdn:is formatted, and the authentication ID sent isdn:<DN>. Spaces afterdn:are ignored, as for the OAuth2 template, sodn: {username}reads asdn:{username}.authzIdTemplate(AuthzIdTemplate): adn:template which is just one placeholder, such asdn:{sub}, takes the field value as the DN as well. Without this, it failed in the same way as the defaultsimpletemplate.CredentialExtractors): the credentials are split at the first:, as RFC 7617 §2 requires, and the scheme must be followed by a space. The case-insensitive match no longer depends on the default locale. Credentials which do not decode as base64, such asBasic abcor unpadded base64, are "no credentials" instead of aNullPointerException.HttpBasicAuthenticationFilter): refused with 401 before any bind. A simple bind with a DN and an empty password is an unauthenticated bind (RFC 4513 §5.1.2). A server which accepts it would let the request run as the named user through proxied authorization without checking any password. Before this change,user:from the Basic header was "no credentials" and fell through to the next authorization mechanism; an empty alternative password header already reached the bind strategy. The server's own HTTP Basic mechanism (HttpBasicAuthorizationMechanism) uses the same filter and extractor, so it gets both changes.%in templates:DnTemplate,AuthzIdTemplateand the{username}templates of thebasicconfiguration (bindDnTemplate,authzIdTemplate,filterTemplate) escape%in their fixed parts. The configuration defaults are now{username}andu:{username}instead of the raw format strings%sandu:%s. A literal%swritten inconfig.json, which was never documented, is now kept as text.DnTemplate: a trailing,..is stripped only when its comma is not escaped (an even number of backslashes before it).appendix-rest2ldap.adoc) says how a template which is just{username}or one field is read, and gives thesasl-plaindefault.Departures from the issue text: an empty password is parsed but refused (see above), and a user name which does not give a DN is reported as
INVALID_CREDENTIALS(401) rather thanINVALID_DN_SYNTAX, which maps to 400.Test
SimpleBindStrategyTest(new, in-memory backend): the default template binds with a DN user name. A template escapes the user name, sobjensen,ou=Peoplecannot add RDNs. A user name which is not a DN fails the promise withINVALID_CREDENTIALSand takes no connection.SaslPlainStrategyTest(new): the authentication ID sent fordn:%s,dn: %s,dn:uid=%s,...(escaped) andu:%s. A user name which is not a DN fails without taking a connection or sending a bind.BasicJsonConfigurationTestCase(new): thebasicconfiguration defaults forsimpleandsasl-plain,dn:{username}, and%in all three templates, read from the request sent to the server.CredentialExtractorsTest:bjensen:se:cret,bjensen::, a DN user name andbjensen:.testBasicReturnNullOnInvalidCredentialsused to fillheadersand then passnew Headers(); it now checks the headers it builds, including a bareBasic,Basic abcand unpadded base64.HttpBasicAuthenticationFilterTest: an empty password gives 401 and the strategy is never called.AuthzIdTemplateTest,DnTemplateTest:%in the fixed part,dn:{dn}anddn: {dn}with a DN value,\,..and\\,...Results:
opendj-rest2ldapsuite passes (571 tests). Javadoc doclint passes, and a broken{@link}added as a negative control fails it.DN.valueOf(String.format(...))for every template, the empty password let through, any backslash escaping the comma, the old%sdefault, a split at the last colon, nonullcheck afterBase64.decode, no trim afterdn:, andgetConnectionAsync()called before the user name is checked, inSimpleBindStrategyand inSaslPlainStrategy.Fixes #1155