From e4cae49dd12e068bc622b605e7234211468c9bef Mon Sep 17 00:00:00 2001 From: Thierry Boileau Date: Sat, 31 Oct 2026 21:21:36 +0100 Subject: [PATCH 1/6] HTTP Basic: opt-in UTF-8 credentials (RFC 7617), read token68 challenges - New opt-in, disabled by default: with the system property org.restlet.engine.security.basic.utf8=true a Basic challenge advertises charset="UTF-8", a server decodes the credentials as UTF-8 (falling back to ISO-8859-1) and a client answering such a challenge encodes UTF-8. Issue #1455. - A WWW-Authenticate challenge made of a token68 ending with "=" (Negotiate) was dropped with the whole header. Issue #191. Co-Authored-By: Claude Sonnet 5.5 --- changes.md | 2 + .../engine/header/ChallengeRequestReader.java | 44 +++++ .../engine/security/AuthenticatorUtils.java | 7 + .../engine/security/HttpBasicHelper.java | 44 ++++- .../security/ChallengeAuthenticator.java | 10 +- .../ChallengeRequestReaderTestCase.java | 82 ++++++++ .../AuthenticatorUtilsParseTestCase.java | 38 ++++ .../security/HttpBasicHelperTestCase.java | 187 ++++++++++++++++++ ...ChallengeAuthenticatorCharsetTestCase.java | 56 ++++++ .../restlet/security/HttpBasicTestCase.java | 40 ++++ 10 files changed, 508 insertions(+), 2 deletions(-) create mode 100644 org.restlet.java/org.restlet/src/test/java/org/restlet/engine/header/ChallengeRequestReaderTestCase.java create mode 100644 org.restlet.java/org.restlet/src/test/java/org/restlet/engine/security/AuthenticatorUtilsParseTestCase.java create mode 100644 org.restlet.java/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java create mode 100644 org.restlet.java/org.restlet/src/test/java/org/restlet/security/ChallengeAuthenticatorCharsetTestCase.java diff --git a/changes.md b/changes.md index 393076b2d0..3321d1684f 100644 --- a/changes.md +++ b/changes.md @@ -5,11 +5,13 @@ Changes log - Misc - Added an optional origin matcher to CORS (`CorsService`, `CorsFilter`, `setAllowedOriginMatcher(Predicate)`) to accept origins that are not listed, for example any sub-domain. The response always contains the exact request origin; it has no effect while the allowed origins contain `*`. Issue #1611. - CorsService and CorsFilter now log a warning when `*` is allowed as origin together with credentials (any web site could then send credentialed requests), and their Javadoc references the Fetch standard instead of the W3C CORS draft. Issue #1611. + - Added an opt-in UTF-8 handling of the HTTP Basic credentials (RFC 7617), enabled with the `org.restlet.engine.security.basic.utf8` system property (`HttpBasicHelper.UTF8_PROPERTY`): a Basic challenge then advertises `charset="UTF-8"`, a server decodes the credentials as UTF-8 (falling back to ISO-8859-1 if they are not valid UTF-8), and a client answering such a challenge encodes its credentials in UTF-8. Disabled by default (ISO-8859-1). Issue #1455. - Bugs fixed - Missing throw in JsonConverter.java. Issue #729. - Type mismatch on container access in MethodAnnotationInfo. Issue #1278. - Boolean canAppend in ZipClientHelper can only be false. Issue #1279. - CorsService and CorsFilter now add `Vary: Origin` to every response when the CORS answer depends on the request origin (origin allow-list or credentials allowed), including requests without an `Origin` header, so shared caches no longer serve the response of one origin to another. A static `*` without credentials still sends no `Vary`, as recommended by the Fetch standard. Issue #1611. + - Fixed the parsing of a `WWW-Authenticate` challenge made of a token68 ending with `=` (such as a `Negotiate` token): the whole header was dropped. Issue #191. - 2.6.1 (30-09-2026) - Bugs fixed diff --git a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/header/ChallengeRequestReader.java b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/header/ChallengeRequestReader.java index 4a90958189..02918378c7 100644 --- a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/header/ChallengeRequestReader.java +++ b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/header/ChallengeRequestReader.java @@ -55,6 +55,13 @@ public HeaderWriter append(Parameter value) { } }; + // A challenge may carry a single token68 instead of parameters + // (RFC 9110, section 11.2), such as a base64 Negotiate token. + String token68 = readToken68(); + if (token68 != null) { + w.append(token68); + } + boolean stop = false; while (peek() != -1 && !stop) { boolean sepSkipped = skipValueSeparator(); @@ -83,4 +90,41 @@ public HeaderWriter append(Parameter value) { return result; } + + /** + * Reads a token68 ending with at least one "=" padding character, if the next + * characters are one. A token68 without padding is read as a regular parameter, + * as before. + * + * @return The token68, or null (nothing consumed) if the next characters are + * not one. + */ + private String readToken68() { + mark(); + StringBuilder token = new StringBuilder(); + int next = peek(); + while (next != -1 && isToken68Char(next)) { + token.append((char) read()); + next = peek(); + } + + int padding = 0; + while (next == '=') { + token.append((char) read()); + padding++; + next = peek(); + } + + if (token.length() > padding && padding > 0 && (next == -1 || next == ',' || isSpace(next))) { + return token.toString(); + } + + reset(); + return null; + } + + private static boolean isToken68Char(int c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '.' + || c == '_' || c == '~' || c == '+' || c == '/'; + } } diff --git a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java index df08c74e88..b7278dec86 100644 --- a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java +++ b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java @@ -357,6 +357,13 @@ public static void update(ChallengeResponse challengeResponse, Request request, realm = challengeRequest.getRealm(); nonce = challengeRequest.getServerNonce(); challengeResponse.setOpaque(challengeRequest.getOpaque()); + + // Credentials charset advertised by the server (RFC 7617, section 2.1) + String charset = challengeRequest.getParameters().getFirstValue("charset"); + if (HttpBasicHelper.isUtf8Enabled() && ChallengeScheme.HTTP_BASIC.equals(challengeResponse.getScheme()) + && charset != null && challengeResponse.getParameters().getFirstValue("charset") == null) { + challengeResponse.getParameters().add("charset", charset); + } } challengeResponse.setRealm(realm); diff --git a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java index 4a884e54ca..36c3cd04ae 100644 --- a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java +++ b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java @@ -21,6 +21,10 @@ import java.io.CharArrayWriter; import java.io.IOException; import java.io.UnsupportedEncodingException; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.Objects; import java.util.logging.Level; @@ -32,6 +36,43 @@ */ public class HttpBasicHelper extends AuthenticatorHelper { + /** + * Name of the system property that enables the UTF-8 handling of the HTTP Basic + * credentials (RFC 7617, section 2.1). Disabled by default, to keep the + * historical ISO-8859-1 behavior. When set to "true": a server challenge + * advertises {@code charset="UTF-8"}, credentials received by a server are + * decoded as UTF-8 (falling back to ISO-8859-1 if they are not valid UTF-8) and + * a client answering a challenge that advertises UTF-8 encodes its credentials + * in UTF-8. + */ + public static final String UTF8_PROPERTY = "org.restlet.engine.security.basic.utf8"; + + /** + * Indicates if the UTF-8 handling of the HTTP Basic credentials is enabled. + * + * @return True if the {@link #UTF8_PROPERTY} system property is "true". + * @see #UTF8_PROPERTY + */ + public static boolean isUtf8Enabled() { + return Boolean.getBoolean(UTF8_PROPERTY); + } + + /** + * Decodes the credentials sent by a client. The bytes are read as UTF-8 first + * and, if they are not valid UTF-8, as ISO-8859-1, the historical default. + * + * @param bytes The decoded Base64 credentials. + * @return The "user-id:password" string. + */ + private static String decodeUtf8First(byte[] bytes) { + try { + return StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes)).toString(); + } catch (CharacterCodingException e) { + return new String(bytes, StandardCharsets.ISO_8859_1); + } + } + /** * Constructor. */ @@ -152,7 +193,8 @@ public void parseResponse(ChallengeResponse challenge, Request request, Series + * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.engine.header; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.restlet.data.ChallengeRequest; + +class ChallengeRequestReaderTestCase { + + @Test + void readValue_schemeOnly_parsesSchemeName() throws IOException { + ChallengeRequestReader reader = new ChallengeRequestReader("Basic"); + ChallengeRequest result = reader.readValue(); + assertEquals("Basic", result.getScheme().getTechnicalName()); + } + + @Test + void readValue_schemeWithParameters_capturesRawValue() throws IOException { + ChallengeRequestReader reader = new ChallengeRequestReader("Digest realm=\"testrealm\""); + ChallengeRequest result = reader.readValue(); + assertEquals("Digest", result.getScheme().getTechnicalName()); + assertTrue(result.getRawValue().contains("realm")); + } + + @Test + void readValue_emptyHeader_returnsNull() throws IOException { + ChallengeRequestReader reader = new ChallengeRequestReader(""); + assertNull(reader.readValue()); + } + + @Test + void readValues_twoChallenges_returnsBoth() throws IOException { + List result = + new ChallengeRequestReader("Basic realm=\"a\", Digest realm=\"b\", nonce=\"x\"") + .readValues(); + assertEquals(2, result.size()); + assertEquals("Basic", result.get(0).getScheme().getTechnicalName()); + assertEquals("realm=a", result.get(0).getRawValue()); + assertEquals("Digest", result.get(1).getScheme().getTechnicalName()); + assertEquals("realm=b, nonce=x", result.get(1).getRawValue()); + } + + @Test + void readValues_schemeWithoutParametersThenChallenge_returnsBoth() throws IOException { + List result = + new ChallengeRequestReader("Negotiate, Basic realm=\"a\"").readValues(); + assertEquals(2, result.size()); + assertEquals("Negotiate", result.get(0).getScheme().getTechnicalName()); + assertEquals("Basic", result.get(1).getScheme().getTechnicalName()); + } + + @Test + void readValues_commaInsideQuotedValue_staysInOneChallenge() throws IOException { + List result = + new ChallengeRequestReader("Basic realm=\"a, Digest b\"").readValues(); + assertEquals(1, result.size()); + assertEquals("Basic", result.get(0).getScheme().getTechnicalName()); + } + + /** RFC 9110 section 11.2: token68 may end with "=" padding, as Negotiate tokens do. */ + @Test + void readValues_token68Challenge_keepsToken() throws IOException { + List result = + new ChallengeRequestReader("Negotiate YIIabc==, Basic realm=\"a\"").readValues(); + assertEquals(2, result.size()); + assertEquals("Negotiate", result.get(0).getScheme().getTechnicalName()); + assertTrue(result.get(0).getRawValue().contains("YIIabc")); + assertEquals("Basic", result.get(1).getScheme().getTechnicalName()); + } +} diff --git a/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/security/AuthenticatorUtilsParseTestCase.java b/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/security/AuthenticatorUtilsParseTestCase.java new file mode 100644 index 0000000000..a5f2300500 --- /dev/null +++ b/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/security/AuthenticatorUtilsParseTestCase.java @@ -0,0 +1,38 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.engine.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.data.ChallengeRequest; +import org.restlet.data.ChallengeScheme; +import org.restlet.data.Method; + +/** Unit tests for the parsing of WWW-Authenticate headers in {@link AuthenticatorUtils}. */ +class AuthenticatorUtilsParseTestCase { + + @Test + void parseRequest_twoChallenges_eachGetsItsSchemeAndRealm() { + List result = + AuthenticatorUtils.parseRequest( + new Response(new Request(Method.GET, "http://localhost/")), + "Basic realm=\"my realm\", Bearer error=\"x\"", + null); + + assertEquals(2, result.size()); + assertEquals(ChallengeScheme.HTTP_BASIC, result.get(0).getScheme()); + assertEquals("my realm", result.get(0).getRealm()); + assertEquals("Bearer", result.get(1).getScheme().getTechnicalName()); + assertEquals("error=x", result.get(1).getRawValue()); + } +} diff --git a/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java b/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java new file mode 100644 index 0000000000..8779ee95c4 --- /dev/null +++ b/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java @@ -0,0 +1,187 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.engine.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.data.ChallengeRequest; +import org.restlet.data.ChallengeResponse; +import org.restlet.data.ChallengeScheme; +import org.restlet.data.Method; +import org.restlet.data.Parameter; + +/** + * Unit tests for the credentials charset handling of {@link HttpBasicHelper} (RFC 7617 section + * 2.1), without any network. + */ +class HttpBasicHelperTestCase { + + @BeforeEach + void enableUtf8() { + System.setProperty(HttpBasicHelper.UTF8_PROPERTY, "true"); + } + + @AfterEach + void restoreDefault() { + System.clearProperty(HttpBasicHelper.UTF8_PROPERTY); + } + + private static final String LATIN1_LOGIN = "josé"; + private static final String LATIN1_PASSWORD = "pässwörd"; + private static final String UTF8_LOGIN = "zoë"; + + /** The euro sign does not exist in ISO-8859-1. */ + private static final String UTF8_PASSWORD = "p€ss"; + + private static String basicHeader(String login, String password, java.nio.charset.Charset cs) { + byte[] bytes = (login + ":" + password).getBytes(cs); + return "Basic " + Base64.getEncoder().encodeToString(bytes); + } + + private static String base64Of(String login, String password, java.nio.charset.Charset cs) { + return Base64.getEncoder().encodeToString((login + ":" + password).getBytes(cs)); + } + + // ---- Server side: decoding the Authorization header -------------------------------------- + + @Test + void parseResponse_asciiCredentials_decoded() { + ChallengeResponse cr = + AuthenticatorUtils.parseResponse( + new Request(Method.GET, "http://localhost/"), + basicHeader("user", "secret", StandardCharsets.UTF_8), + null); + assertEquals("user", cr.getIdentifier()); + assertEquals("secret", String.valueOf(cr.getSecret())); + } + + /** Backward compatibility: bytes that are not valid UTF-8 are still read as ISO-8859-1. */ + @Test + void parseResponse_latin1Bytes_decodedAsIso88591() { + ChallengeResponse cr = + AuthenticatorUtils.parseResponse( + new Request(Method.GET, "http://localhost/"), + basicHeader(LATIN1_LOGIN, LATIN1_PASSWORD, StandardCharsets.ISO_8859_1), + null); + assertEquals(LATIN1_LOGIN, cr.getIdentifier()); + assertEquals(LATIN1_PASSWORD, String.valueOf(cr.getSecret())); + } + + /** Issue #1455: the server advertises UTF-8 but decodes ISO-8859-1. */ + @Test + void parseResponse_utf8Bytes_decodedAsUtf8() { + ChallengeResponse cr = + AuthenticatorUtils.parseResponse( + new Request(Method.GET, "http://localhost/"), + basicHeader(UTF8_LOGIN, UTF8_PASSWORD, StandardCharsets.UTF_8), + null); + assertEquals(UTF8_LOGIN, cr.getIdentifier()); + assertEquals(UTF8_PASSWORD, String.valueOf(cr.getSecret())); + } + + /** The default is unchanged: without the opt-in, credentials are read as ISO-8859-1. */ + @Test + void parseResponse_utf8Bytes_utf8Disabled_decodedAsIso88591() { + System.clearProperty(HttpBasicHelper.UTF8_PROPERTY); + ChallengeResponse cr = + AuthenticatorUtils.parseResponse( + new Request(Method.GET, "http://localhost/"), + basicHeader("zoë", "pw", StandardCharsets.UTF_8), + null); + assertEquals("zoë", cr.getIdentifier()); + } + + @Test + void formatResponse_challengeAdvertisedUtf8_utf8Disabled_encodesIso88591() { + System.clearProperty(HttpBasicHelper.UTF8_PROPERTY); + Response response = new Response(new Request(Method.GET, "http://localhost/")); + ChallengeRequest challenge = new ChallengeRequest(ChallengeScheme.HTTP_BASIC, "realm"); + challenge.getParameters().add(new Parameter("charset", "UTF-8")); + response.getChallengeRequests().add(challenge); + + ChallengeResponse cr = + new ChallengeResponse(challenge, response, "josé", "pässwörd".toCharArray()); + + assertEquals( + "Basic " + base64Of("josé", "pässwörd", StandardCharsets.ISO_8859_1), + AuthenticatorUtils.formatResponse(cr, response.getRequest(), null)); + } + + // ---- Server side: advertising the charset in the challenge ------------------------------- + + @Test + void formatRequest_charsetParameter_advertisesUtf8() { + ChallengeRequest challenge = new ChallengeRequest(ChallengeScheme.HTTP_BASIC, "realm"); + challenge.getParameters().add(new Parameter("charset", "UTF-8")); + + assertEquals( + "Basic realm=\"realm\", charset=\"UTF-8\"", + AuthenticatorUtils.formatRequest(challenge, new Response(null), null)); + } + + @Test + void formatRequest_noCharsetParameter_realmOnly() { + ChallengeRequest challenge = new ChallengeRequest(ChallengeScheme.HTTP_BASIC, "realm"); + + assertEquals( + "Basic realm=\"realm\"", + AuthenticatorUtils.formatRequest(challenge, new Response(null), null)); + } + + // ---- Client side: encoding the Authorization header -------------------------------------- + + @Test + void formatResponse_default_encodesIso88591() { + ChallengeResponse cr = + new ChallengeResponse(ChallengeScheme.HTTP_BASIC, LATIN1_LOGIN, LATIN1_PASSWORD); + + assertEquals( + "Basic " + base64Of(LATIN1_LOGIN, LATIN1_PASSWORD, StandardCharsets.ISO_8859_1), + AuthenticatorUtils.formatResponse( + cr, new Request(Method.GET, "http://localhost/"), null)); + } + + @Test + void formatResponse_charsetParameter_encodesUtf8() { + ChallengeResponse cr = + new ChallengeResponse(ChallengeScheme.HTTP_BASIC, UTF8_LOGIN, UTF8_PASSWORD); + cr.getParameters().add(new Parameter("charset", "UTF-8")); + + assertEquals( + "Basic " + base64Of(UTF8_LOGIN, UTF8_PASSWORD, StandardCharsets.UTF_8), + AuthenticatorUtils.formatResponse( + cr, new Request(Method.GET, "http://localhost/"), null)); + } + + /** + * Issue #1455: a client that answers a challenge advertising {@code charset="UTF-8"} should + * encode its credentials in UTF-8 without any manual parameter. + */ + @Test + void formatResponse_challengeAdvertisedUtf8_encodesUtf8() { + Response response = new Response(new Request(Method.GET, "http://localhost/")); + ChallengeRequest challenge = new ChallengeRequest(ChallengeScheme.HTTP_BASIC, "realm"); + challenge.getParameters().add(new Parameter("charset", "UTF-8")); + response.getChallengeRequests().add(challenge); + + ChallengeResponse cr = + new ChallengeResponse(challenge, response, UTF8_LOGIN, UTF8_PASSWORD.toCharArray()); + + assertEquals( + "Basic " + base64Of(UTF8_LOGIN, UTF8_PASSWORD, StandardCharsets.UTF_8), + AuthenticatorUtils.formatResponse(cr, response.getRequest(), null)); + } +} diff --git a/org.restlet.java/org.restlet/src/test/java/org/restlet/security/ChallengeAuthenticatorCharsetTestCase.java b/org.restlet.java/org.restlet/src/test/java/org/restlet/security/ChallengeAuthenticatorCharsetTestCase.java new file mode 100644 index 0000000000..c7ec71d68e --- /dev/null +++ b/org.restlet.java/org.restlet/src/test/java/org/restlet/security/ChallengeAuthenticatorCharsetTestCase.java @@ -0,0 +1,56 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.restlet.data.ChallengeRequest; +import org.restlet.data.ChallengeScheme; +import org.restlet.engine.security.HttpBasicHelper; + +/** Unit tests for the credentials charset advertised by {@link ChallengeAuthenticator}. */ +class ChallengeAuthenticatorCharsetTestCase { + + @AfterEach + void restoreDefault() { + System.clearProperty(HttpBasicHelper.UTF8_PROPERTY); + } + + /** Issue #1455: a Basic challenge advertises UTF-8 credentials when enabled. */ + @Test + void createChallengeRequest_basic_advertisesUtf8() { + System.setProperty(HttpBasicHelper.UTF8_PROPERTY, "true"); + ChallengeRequest challenge = + new ChallengeAuthenticator(null, ChallengeScheme.HTTP_BASIC, "realm") + .createChallengeRequest(false); + assertEquals("realm", challenge.getRealm()); + assertEquals("UTF-8", challenge.getParameters().getFirstValue("charset")); + } + + /** The default is unchanged: without the opt-in, no charset is advertised. */ + @Test + void createChallengeRequest_basic_utf8Disabled_hasNoCharsetParameter() { + ChallengeRequest challenge = + new ChallengeAuthenticator(null, ChallengeScheme.HTTP_BASIC, "realm") + .createChallengeRequest(false); + assertNull(challenge.getParameters().getFirstValue("charset")); + } + + @Test + void createChallengeRequest_digest_hasNoCharsetParameter() { + System.setProperty(HttpBasicHelper.UTF8_PROPERTY, "true"); + ChallengeRequest challenge = + new ChallengeAuthenticator(null, ChallengeScheme.HTTP_DIGEST, "realm") + .createChallengeRequest(false); + assertNull(challenge.getParameters().getFirstValue("charset")); + } +} diff --git a/org.restlet.java/org.restlet/src/test/java/org/restlet/security/HttpBasicTestCase.java b/org.restlet.java/org.restlet/src/test/java/org/restlet/security/HttpBasicTestCase.java index 328c2ab542..c260c72e53 100644 --- a/org.restlet.java/org.restlet/src/test/java/org/restlet/security/HttpBasicTestCase.java +++ b/org.restlet.java/org.restlet/src/test/java/org/restlet/security/HttpBasicTestCase.java @@ -35,6 +35,7 @@ import org.restlet.data.Method; import org.restlet.data.Protocol; import org.restlet.data.Status; +import org.restlet.engine.security.HttpBasicHelper; /** * Restlet unit tests for HTTP Basic authentication client/server. @@ -44,6 +45,12 @@ */ public class HttpBasicTestCase { + public static final String LATIN1_USERNAME = "jos\u00e9"; + public static final String LATIN1_PASSWORD = "p\u00e4ssw\u00f6rd"; + /** The euro sign does not exist in ISO-8859-1. */ + public static final String UTF8_USERNAME = "zo\u00eb"; + public static final String UTF8_PASSWORD = "p\u20acss"; + public static class AuthenticatedRestlet extends Restlet { @Override public void handle(Request request, Response response) { @@ -55,6 +62,8 @@ public static class TestVerifier extends MapVerifier { public TestVerifier() { getLocalSecrets().put(SHORT_USERNAME, SHORT_PASSWORD.toCharArray()); getLocalSecrets().put(LONG_USERNAME, LONG_PASSWORD.toCharArray()); + getLocalSecrets().put(LATIN1_USERNAME, LATIN1_PASSWORD.toCharArray()); + getLocalSecrets().put(UTF8_USERNAME, UTF8_PASSWORD.toCharArray()); } @Override @@ -152,6 +161,36 @@ void testValidCredentials(final String login, final String password) throws Exce assertEquals(AUTHENTICATED_MSG, response.getEntity().getText()); } + /** Clients that predate RFC 7617 and send ISO-8859-1 must keep working. */ + @Test + public void testLatin1CredentialsWithoutCharset() throws Exception { + request.setChallengeResponse( + new ChallengeResponse(ChallengeScheme.HTTP_BASIC, LATIN1_USERNAME, LATIN1_PASSWORD)); + + assertEquals(Status.SUCCESS_OK, client.handle(request).getStatus()); + } + + /** Issue #1455: credentials sent as UTF-8 are accepted by the server. */ + @Test + public void testUtf8CredentialsWithCharset() throws Exception { + System.setProperty(HttpBasicHelper.UTF8_PROPERTY, "true"); + ChallengeResponse authentication = new ChallengeResponse(ChallengeScheme.HTTP_BASIC, UTF8_USERNAME, + UTF8_PASSWORD); + authentication.getParameters().add("charset", "UTF-8"); + request.setChallengeResponse(authentication); + + assertEquals(Status.SUCCESS_OK, client.handle(request).getStatus()); + } + + /** Issue #1455: the server challenge carries charset="UTF-8" when enabled. */ + @Test + public void testChallengeAdvertisesUtf8() throws Exception { + System.setProperty(HttpBasicHelper.UTF8_PROPERTY, "true"); + Response response = client.handle(request); + assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus()); + assertEquals("UTF-8", response.getChallengeRequests().get(0).getParameters().getFirstValue("charset")); + } + @BeforeEach public void makeServer() throws Exception { final String REALM = HttpBasicTestCase.class.getSimpleName(); @@ -177,6 +216,7 @@ public Restlet createInboundRoot() { @AfterEach public void cleanup() throws Exception { + System.clearProperty(HttpBasicHelper.UTF8_PROPERTY); client.stop(); if (this.component.isStarted()) { this.component.stop(); From 42bdff9647fd1187c158f1871750da9707f34bcd Mon Sep 17 00:00:00 2001 From: Thierry Boileau Date: Sat, 31 Oct 2026 21:48:21 +0100 Subject: [PATCH 2/6] HTTP Basic: match the realm and charset parameters case-insensitively RFC 9110 section 11.2 makes auth-param names case-insensitive. "Realm=" was ignored (null realm) and "Charset=" was not seen by the client. Co-Authored-By: Claude Sonnet 5.5 --- changes.md | 1 + .../engine/security/AuthenticatorUtils.java | 4 ++-- .../engine/security/HttpBasicHelper.java | 8 +++---- .../security/HttpBasicHelperTestCase.java | 21 +++++++++++++++++++ 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/changes.md b/changes.md index 3321d1684f..5b71bb0072 100644 --- a/changes.md +++ b/changes.md @@ -12,6 +12,7 @@ Changes log - Boolean canAppend in ZipClientHelper can only be false. Issue #1279. - CorsService and CorsFilter now add `Vary: Origin` to every response when the CORS answer depends on the request origin (origin allow-list or credentials allowed), including requests without an `Origin` header, so shared caches no longer serve the response of one origin to another. A static `*` without credentials still sends no `Vary`, as recommended by the Fetch standard. Issue #1611. - Fixed the parsing of a `WWW-Authenticate` challenge made of a token68 ending with `=` (such as a `Negotiate` token): the whole header was dropped. Issue #191. + - The `realm` and `charset` parameters of an HTTP Basic challenge are now recognized whatever their case (`Realm=`, `Charset=`), as required by RFC 9110. Issue #1455. - 2.6.1 (30-09-2026) - Bugs fixed diff --git a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java index b7278dec86..5fc4df5e0a 100644 --- a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java +++ b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java @@ -359,9 +359,9 @@ public static void update(ChallengeResponse challengeResponse, Request request, challengeResponse.setOpaque(challengeRequest.getOpaque()); // Credentials charset advertised by the server (RFC 7617, section 2.1) - String charset = challengeRequest.getParameters().getFirstValue("charset"); + String charset = challengeRequest.getParameters().getFirstValue("charset", true); if (HttpBasicHelper.isUtf8Enabled() && ChallengeScheme.HTTP_BASIC.equals(challengeResponse.getScheme()) - && charset != null && challengeResponse.getParameters().getFirstValue("charset") == null) { + && charset != null && challengeResponse.getParameters().getFirstValue("charset", true) == null) { challengeResponse.getParameters().add("charset", charset); } } diff --git a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java index 36c3cd04ae..7ad47863f5 100644 --- a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java +++ b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java @@ -84,7 +84,7 @@ public HttpBasicHelper() { public void formatRequest(ChallengeWriter cw, ChallengeRequest challenge, Response response, Series

httpHeaders) throws IOException { String realm = challenge.getRealm(); - String charset = challenge.getParameters().getFirstValue("charset"); + String charset = challenge.getParameters().getFirstValue("charset", true); if (realm != null) { cw.appendQuotedChallengeParameter("realm", realm); @@ -109,7 +109,7 @@ public void formatResponse(ChallengeWriter cw, ChallengeResponse challenge, Requ if (challenge == null) { throw new RuntimeException("No challenge provided, unable to encode credentials"); } else { - String charset = challenge.getParameters().getFirstValue("charset"); + String charset = challenge.getParameters().getFirstValue("charset", true); if (charset != null) { if ("UTF-8".equalsIgnoreCase(charset)) { @@ -146,7 +146,7 @@ public void parseRequest(ChallengeRequest challenge, Response response, Series Date: Sat, 31 Oct 2026 21:52:23 +0100 Subject: [PATCH 3/6] HTTP Basic tests: register their own engine Another test of the suite can leave a Restlet engine without the Basic authenticator helper (seen on CI, depending on the test order): the new tests now register a complete engine first. Co-Authored-By: Claude Sonnet 5.5 --- .../engine/security/AuthenticatorUtilsParseTestCase.java | 2 ++ .../org/restlet/engine/security/HttpBasicHelperTestCase.java | 2 ++ 2 files changed, 4 insertions(+) diff --git a/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/security/AuthenticatorUtilsParseTestCase.java b/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/security/AuthenticatorUtilsParseTestCase.java index a5f2300500..be2c9e9dd5 100644 --- a/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/security/AuthenticatorUtilsParseTestCase.java +++ b/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/security/AuthenticatorUtilsParseTestCase.java @@ -17,12 +17,14 @@ import org.restlet.data.ChallengeRequest; import org.restlet.data.ChallengeScheme; import org.restlet.data.Method; +import org.restlet.engine.Engine; /** Unit tests for the parsing of WWW-Authenticate headers in {@link AuthenticatorUtils}. */ class AuthenticatorUtilsParseTestCase { @Test void parseRequest_twoChallenges_eachGetsItsSchemeAndRealm() { + Engine.register(); // other tests may leave an engine without the Basic helper List result = AuthenticatorUtils.parseRequest( new Response(new Request(Method.GET, "http://localhost/")), diff --git a/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java b/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java index f3c57298a9..49dc5122fe 100644 --- a/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java +++ b/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java @@ -22,6 +22,7 @@ import org.restlet.data.ChallengeScheme; import org.restlet.data.Method; import org.restlet.data.Parameter; +import org.restlet.engine.Engine; /** * Unit tests for the credentials charset handling of {@link HttpBasicHelper} (RFC 7617 section @@ -31,6 +32,7 @@ class HttpBasicHelperTestCase { @BeforeEach void enableUtf8() { + Engine.register(); // other tests may leave an engine without the Basic helper System.setProperty(HttpBasicHelper.UTF8_PROPERTY, "true"); } From 3b613472c697f479295b05419210cbb8c7aac4d2 Mon Sep 17 00:00:00 2001 From: Thierry Boileau Date: Sat, 31 Oct 2026 21:57:33 +0100 Subject: [PATCH 4/6] HTTP Basic: address the SonarCloud findings on the new code Co-Authored-By: Claude Sonnet 5.5 --- .../engine/security/AuthenticatorUtils.java | 9 +++-- .../engine/security/HttpBasicHelper.java | 33 +++++++++++-------- .../ChallengeRequestReaderTestCase.java | 14 ++++---- .../restlet/security/HttpBasicTestCase.java | 6 ++-- 4 files changed, 35 insertions(+), 27 deletions(-) diff --git a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java index 5fc4df5e0a..17bd072fd8 100644 --- a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java +++ b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java @@ -33,6 +33,9 @@ */ public class AuthenticatorUtils { + /** Name of the challenge parameter that advertises the credentials charset. */ + private static final String CHARSET_PARAMETER = "charset"; + /** * Indicates if any of the objects is null. * @@ -359,10 +362,10 @@ public static void update(ChallengeResponse challengeResponse, Request request, challengeResponse.setOpaque(challengeRequest.getOpaque()); // Credentials charset advertised by the server (RFC 7617, section 2.1) - String charset = challengeRequest.getParameters().getFirstValue("charset", true); + String charset = challengeRequest.getParameters().getFirstValue(CHARSET_PARAMETER, true); if (HttpBasicHelper.isUtf8Enabled() && ChallengeScheme.HTTP_BASIC.equals(challengeResponse.getScheme()) - && charset != null && challengeResponse.getParameters().getFirstValue("charset", true) == null) { - challengeResponse.getParameters().add("charset", charset); + && charset != null && challengeResponse.getParameters().getFirstValue(CHARSET_PARAMETER, true) == null) { + challengeResponse.getParameters().add(CHARSET_PARAMETER, charset); } } diff --git a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java index 7ad47863f5..0b3365bc3a 100644 --- a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java +++ b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java @@ -26,7 +26,6 @@ import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.util.Base64; -import java.util.Objects; import java.util.logging.Level; /** @@ -47,6 +46,12 @@ public class HttpBasicHelper extends AuthenticatorHelper { */ public static final String UTF8_PROPERTY = "org.restlet.engine.security.basic.utf8"; + private static final String CHARSET = "charset"; + + private static final String UTF_8 = "UTF-8"; + + private static final String ISO_8859_1 = "ISO-8859-1"; + /** * Indicates if the UTF-8 handling of the HTTP Basic credentials is enabled. * @@ -84,7 +89,7 @@ public HttpBasicHelper() { public void formatRequest(ChallengeWriter cw, ChallengeRequest challenge, Response response, Series
httpHeaders) throws IOException { String realm = challenge.getRealm(); - String charset = challenge.getParameters().getFirstValue("charset", true); + String charset = challenge.getParameters().getFirstValue(CHARSET, true); if (realm != null) { cw.appendQuotedChallengeParameter("realm", realm); @@ -94,8 +99,8 @@ public void formatRequest(ChallengeWriter cw, ChallengeRequest challenge, Respon } if (charset != null) { - if ("UTF-8".equalsIgnoreCase(charset)) { - cw.appendQuotedChallengeParameter("charset", "UTF-8"); + if (UTF_8.equalsIgnoreCase(charset)) { + cw.appendQuotedChallengeParameter(CHARSET, UTF_8); } else { getLogger().warning("The \"charset\" parameter must be \"UTF-8\" per RFC 7617."); } @@ -109,18 +114,18 @@ public void formatResponse(ChallengeWriter cw, ChallengeResponse challenge, Requ if (challenge == null) { throw new RuntimeException("No challenge provided, unable to encode credentials"); } else { - String charset = challenge.getParameters().getFirstValue("charset", true); + String charset = challenge.getParameters().getFirstValue(CHARSET, true); if (charset != null) { - if ("UTF-8".equalsIgnoreCase(charset)) { - charset = "UTF-8"; + if (UTF_8.equalsIgnoreCase(charset)) { + charset = UTF_8; } else { getLogger().warning( "The \"charset\" parameter must be \"UTF-8\" per RFC 7617. Using \"ISO-8859-1\" instead."); - charset = "ISO-8859-1"; + charset = ISO_8859_1; } } else { - charset = "ISO-8859-1"; + charset = ISO_8859_1; } CharArrayWriter credentials = new CharArrayWriter(); @@ -177,18 +182,18 @@ public void parseResponse(ChallengeResponse challenge, Request request, Series result = new ChallengeRequestReader("Basic realm=\"a\", Digest realm=\"b\", nonce=\"x\"") .readValues(); @@ -53,7 +53,7 @@ void readValues_twoChallenges_returnsBoth() throws IOException { } @Test - void readValues_schemeWithoutParametersThenChallenge_returnsBoth() throws IOException { + void readValues_schemeWithoutParametersThenChallenge_returnsBoth() { List result = new ChallengeRequestReader("Negotiate, Basic realm=\"a\"").readValues(); assertEquals(2, result.size()); @@ -62,7 +62,7 @@ void readValues_schemeWithoutParametersThenChallenge_returnsBoth() throws IOExce } @Test - void readValues_commaInsideQuotedValue_staysInOneChallenge() throws IOException { + void readValues_commaInsideQuotedValue_staysInOneChallenge() { List result = new ChallengeRequestReader("Basic realm=\"a, Digest b\"").readValues(); assertEquals(1, result.size()); @@ -71,7 +71,7 @@ void readValues_commaInsideQuotedValue_staysInOneChallenge() throws IOException /** RFC 9110 section 11.2: token68 may end with "=" padding, as Negotiate tokens do. */ @Test - void readValues_token68Challenge_keepsToken() throws IOException { + void readValues_token68Challenge_keepsToken() { List result = new ChallengeRequestReader("Negotiate YIIabc==, Basic realm=\"a\"").readValues(); assertEquals(2, result.size()); diff --git a/org.restlet.java/org.restlet/src/test/java/org/restlet/security/HttpBasicTestCase.java b/org.restlet.java/org.restlet/src/test/java/org/restlet/security/HttpBasicTestCase.java index c260c72e53..a84adb66c3 100644 --- a/org.restlet.java/org.restlet/src/test/java/org/restlet/security/HttpBasicTestCase.java +++ b/org.restlet.java/org.restlet/src/test/java/org/restlet/security/HttpBasicTestCase.java @@ -163,7 +163,7 @@ void testValidCredentials(final String login, final String password) throws Exce /** Clients that predate RFC 7617 and send ISO-8859-1 must keep working. */ @Test - public void testLatin1CredentialsWithoutCharset() throws Exception { + void testLatin1CredentialsWithoutCharset() { request.setChallengeResponse( new ChallengeResponse(ChallengeScheme.HTTP_BASIC, LATIN1_USERNAME, LATIN1_PASSWORD)); @@ -172,7 +172,7 @@ public void testLatin1CredentialsWithoutCharset() throws Exception { /** Issue #1455: credentials sent as UTF-8 are accepted by the server. */ @Test - public void testUtf8CredentialsWithCharset() throws Exception { + void testUtf8CredentialsWithCharset() { System.setProperty(HttpBasicHelper.UTF8_PROPERTY, "true"); ChallengeResponse authentication = new ChallengeResponse(ChallengeScheme.HTTP_BASIC, UTF8_USERNAME, UTF8_PASSWORD); @@ -184,7 +184,7 @@ public void testUtf8CredentialsWithCharset() throws Exception { /** Issue #1455: the server challenge carries charset="UTF-8" when enabled. */ @Test - public void testChallengeAdvertisesUtf8() throws Exception { + void testChallengeAdvertisesUtf8() { System.setProperty(HttpBasicHelper.UTF8_PROPERTY, "true"); Response response = client.handle(request); assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus()); From 388c534f9ea694cf8723bca39917133857649209 Mon Sep 17 00:00:00 2001 From: Thierry Boileau Date: Sat, 31 Oct 2026 21:58:06 +0100 Subject: [PATCH 5/6] HTTP Basic tests: restore the IOException declaration of the readValue tests Co-Authored-By: Claude Sonnet 5.5 --- .../engine/header/ChallengeRequestReaderTestCase.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/header/ChallengeRequestReaderTestCase.java b/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/header/ChallengeRequestReaderTestCase.java index ea3547225b..1ffe7d2d8a 100644 --- a/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/header/ChallengeRequestReaderTestCase.java +++ b/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/header/ChallengeRequestReaderTestCase.java @@ -20,14 +20,14 @@ class ChallengeRequestReaderTestCase { @Test - void readValue_schemeOnly_parsesSchemeName() { + void readValue_schemeOnly_parsesSchemeName() throws IOException { ChallengeRequestReader reader = new ChallengeRequestReader("Basic"); ChallengeRequest result = reader.readValue(); assertEquals("Basic", result.getScheme().getTechnicalName()); } @Test - void readValue_schemeWithParameters_capturesRawValue() { + void readValue_schemeWithParameters_capturesRawValue() throws IOException { ChallengeRequestReader reader = new ChallengeRequestReader("Digest realm=\"testrealm\""); ChallengeRequest result = reader.readValue(); assertEquals("Digest", result.getScheme().getTechnicalName()); @@ -35,7 +35,7 @@ void readValue_schemeWithParameters_capturesRawValue() { } @Test - void readValue_emptyHeader_returnsNull() { + void readValue_emptyHeader_returnsNull() throws IOException { ChallengeRequestReader reader = new ChallengeRequestReader(""); assertNull(reader.readValue()); } From da6ac56a916e1b03669b7bf8ce1b20dd2831961f Mon Sep 17 00:00:00 2001 From: Thierry Boileau Date: Sun, 1 Nov 2026 11:20:38 +0100 Subject: [PATCH 6/6] Fix Sonar issues --- .../engine/security/AuthenticatorUtils.java | 206 +++++++++--------- .../engine/security/HttpBasicHelper.java | 64 +++--- .../security/ChallengeAuthenticator.java | 147 +++++++------ .../restlet/security/HttpBasicTestCase.java | 17 +- 4 files changed, 233 insertions(+), 201 deletions(-) diff --git a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java index 17bd072fd8..5501d46795 100644 --- a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java +++ b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java @@ -59,40 +59,42 @@ public static boolean anyNull(Object... objects) { * @return The {@link HeaderConstants#HEADER_AUTHENTICATION_INFO} header value. */ public static String formatAuthenticationInfo(AuthenticationInfo info) { - ChallengeWriter cw = new ChallengeWriter(); - boolean firstParameter = true; - - if (info != null) { - if (info.getNextServerNonce() != null && !info.getNextServerNonce().isEmpty()) { - cw.setFirstChallengeParameter(firstParameter); - cw.appendQuotedChallengeParameter("nextnonce", info.getNextServerNonce()); - firstParameter = false; - } - - if (info.getQuality() != null && !info.getQuality().isEmpty()) { - cw.setFirstChallengeParameter(firstParameter); - cw.appendChallengeParameter("qop", info.getQuality()); - firstParameter = false; + final ChallengeWriter cw = new ChallengeWriter(); - if (info.getNonceCount() > 0) { - cw.appendChallengeParameter("nc", formatNonceCount(info.getNonceCount())); - } - } - - if (info.getResponseDigest() != null && !info.getResponseDigest().isEmpty()) { - cw.setFirstChallengeParameter(firstParameter); - cw.appendQuotedChallengeParameter("rspauth", info.getResponseDigest()); - firstParameter = false; - } - - if (info.getClientNonce() != null && !info.getClientNonce().isEmpty()) { - cw.setFirstChallengeParameter(firstParameter); - cw.appendChallengeParameter("cnonce", info.getClientNonce()); - firstParameter = false; - } + if (info == null) { + return cw.toString(); } - return cw.toString(); + boolean firstParameter = true; + if (info.getNextServerNonce() != null && !info.getNextServerNonce().isEmpty()) { + cw.setFirstChallengeParameter(firstParameter); + cw.appendQuotedChallengeParameter("nextnonce", info.getNextServerNonce()); + firstParameter = false; + } + + if (info.getQuality() != null && !info.getQuality().isEmpty()) { + cw.setFirstChallengeParameter(firstParameter); + cw.appendChallengeParameter("qop", info.getQuality()); + firstParameter = false; + + if (info.getNonceCount() > 0) { + cw.appendChallengeParameter("nc", formatNonceCount(info.getNonceCount())); + } + } + + if (info.getResponseDigest() != null && !info.getResponseDigest().isEmpty()) { + cw.setFirstChallengeParameter(firstParameter); + cw.appendQuotedChallengeParameter("rspauth", info.getResponseDigest()); + firstParameter = false; + } + + if (info.getClientNonce() != null && !info.getClientNonce().isEmpty()) { + cw.setFirstChallengeParameter(firstParameter); + cw.appendChallengeParameter("cnonce", info.getClientNonce()); + firstParameter = false; + } + + return cw.toString(); } /** @@ -133,33 +135,42 @@ public static String formatRequest(ChallengeRequest challenge, Response response } else if (challenge.getScheme().getTechnicalName() == null) { Context.getCurrentLogger().warning("A challenge scheme must have a technical name defined."); } else { - ChallengeWriter cw = new ChallengeWriter(); - cw.append(challenge.getScheme().getTechnicalName()).appendSpace(); - int cwInitialLength = cw.getBuffer().length(); + result = doFormatRequest(challenge, response, httpHeaders); + } + + return result; + } - if (challenge.getRawValue() != null) { - cw.append(challenge.getRawValue()); + private static String doFormatRequest(ChallengeRequest challenge, Response response, Series
httpHeaders) { + ChallengeWriter cw = new ChallengeWriter(); + cw.append(challenge.getScheme().getTechnicalName()).appendSpace(); + int cwInitialLength = cw.getBuffer().length(); + + if (challenge.getRawValue() == null) { + AuthenticatorHelper helper = Engine.getInstance().findHelper(challenge.getScheme(), false, true); + + if (helper == null) { + logUnsupportedChallengeScheme(challenge.getScheme()); } else { - AuthenticatorHelper helper = Engine.getInstance().findHelper(challenge.getScheme(), false, true); - - if (helper != null) { - try { - helper.formatRequest(cw, challenge, response, httpHeaders); - } catch (Exception e) { - Context.getCurrentLogger().log(Level.WARNING, - "Unable to format the challenge request: " + challenge, e); - } - } else { - result = "?"; - Context.getCurrentLogger().warning( - "Challenge scheme " + challenge.getScheme() + " not supported by the Restlet engine."); + try { + helper.formatRequest(cw, challenge, response, httpHeaders); + } catch (Exception e) { + Context.getCurrentLogger().log(Level.WARNING, e, + () -> "Unable to format the challenge request: " + challenge); } } - - result = (cw.getBuffer().length() > cwInitialLength) ? cw.toString() : null; + } else { + cw.append(challenge.getRawValue()); } - return result; + return (cw.getBuffer().length() > cwInitialLength) + ? cw.toString() + : null; + } + + private static void logUnsupportedChallengeScheme(ChallengeScheme challengeScheme) { + Context.getCurrentLogger().log(Level.WARNING, + () -> "Challenge scheme " + challengeScheme + " not supported by the Restlet engine."); } /** @@ -185,34 +196,39 @@ public static String formatResponse(ChallengeResponse challenge, Request request } else if (challenge.getScheme().getTechnicalName() == null) { Context.getCurrentLogger().warning("A challenge scheme must have a technical name defined."); } else { - ChallengeWriter cw = new ChallengeWriter(); - cw.append(challenge.getScheme().getTechnicalName()).appendSpace(); - int cwInitialLength = cw.getBuffer().length(); - - if (challenge.getRawValue() != null) { - cw.append(challenge.getRawValue()); - } else { - AuthenticatorHelper helper = Engine.getInstance().findHelper(challenge.getScheme(), true, false); - - if (helper != null) { - try { - helper.formatResponse(cw, challenge, request, httpHeaders); - } catch (Exception e) { - Context.getCurrentLogger().log(Level.WARNING, - "Unable to format the challenge response: " + challenge, e); - } - } else { - Context.getCurrentLogger().warning( - "Challenge scheme " + challenge.getScheme() + " not supported by the Restlet engine."); - } - } - - result = (cw.getBuffer().length() > cwInitialLength) ? cw.toString() : null; + result = doFormatResponse(challenge, request, httpHeaders); } return result; } + private static String doFormatResponse(ChallengeResponse challenge, Request request, Series
httpHeaders) { + ChallengeWriter cw = new ChallengeWriter(); + cw.append(challenge.getScheme().getTechnicalName()).appendSpace(); + int cwInitialLength = cw.getBuffer().length(); + + if (challenge.getRawValue() == null) { + AuthenticatorHelper helper = Engine.getInstance().findHelper(challenge.getScheme(), true, false); + + if (helper == null) { + logUnsupportedChallengeScheme(challenge.getScheme()); + } else { + try { + helper.formatResponse(cw, challenge, request, httpHeaders); + } catch (Exception e) { + Context.getCurrentLogger().log(Level.WARNING, e, + () -> "Unable to format the challenge response: " + challenge); + } + } + } else { + cw.append(challenge.getRawValue()); + } + + return (cw.getBuffer().length() > cwInitialLength) + ? cw.toString() + : null; + } + /** * Parses the "Authentication-Info" header. * @@ -221,7 +237,7 @@ public static String formatResponse(ChallengeResponse challenge, Request request */ public static AuthenticationInfo parseAuthenticationInfo(String header) { AuthenticationInfo result = null; - HeaderReader hr = new HeaderReader(header); + HeaderReader hr = new HeaderReader<>(header); try { String nextNonce = null; @@ -258,8 +274,8 @@ public static AuthenticationInfo parseAuthenticationInfo(String header) { result = new AuthenticationInfo(nextNonce, nonceCount, cnonce, qop, responseAuth); } catch (IOException e) { - Context.getCurrentLogger().log(Level.WARNING, "Unable to parse the authentication info header: " + header, - e); + Context.getCurrentLogger().log(Level.WARNING, e, + () -> "Unable to parse the authentication info header: " + header); } return result; @@ -274,22 +290,20 @@ public static AuthenticationInfo parseAuthenticationInfo(String header) { * @return The list of parsed challenge request. */ public static List parseRequest(Response response, String header, Series
httpHeaders) { - List result = new ArrayList(); + List result = new ArrayList<>(); if (header != null) { result = new ChallengeRequestReader(header).readValues(); for (ChallengeRequest cr : result) { - // Give a chance to the authenticator helper to do further - // parsing + // Give a chance to the authenticator helper to do further parsing AuthenticatorHelper helper = Engine.getInstance().findHelper(cr.getScheme(), true, false); - if (helper != null) { - helper.parseRequest(cr, response, httpHeaders); - } else { - Context.getCurrentLogger() - .warning("Couldn't find any helper support the " + cr.getScheme() + " challenge scheme."); - } - } + if (helper == null) { + logUnsupportedChallengeScheme(cr.getScheme()); + } else { + helper.parseRequest(cr, response, httpHeaders); + } + } } return result; @@ -323,13 +337,12 @@ public static ChallengeResponse parseResponse(Request request, String header, Se // Give a chance to the authenticator helper to do further parsing AuthenticatorHelper helper = Engine.getInstance().findHelper(result.getScheme(), true, false); - if (helper != null) { - helper.parseResponse(result, request, httpHeaders); - } else { - Context.getCurrentLogger() - .warning("Couldn't find any helper support the " + result.getScheme() + " challenge scheme."); - } - } + if (helper == null) { + logUnsupportedChallengeScheme(result.getScheme()); + } else { + helper.parseResponse(result, request, httpHeaders); + } + } return result; @@ -394,8 +407,7 @@ public static Reference updateReference(Reference resourceRef, ChallengeResponse if (helper != null) { resourceRef = helper.updateReference(resourceRef, challengeResponse, request); } else { - Context.getCurrentLogger().warning( - "Challenge scheme " + challengeResponse.getScheme() + " not supported by the Restlet engine."); + logUnsupportedChallengeScheme(challengeResponse.getScheme()); } } diff --git a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java index 0b3365bc3a..ba9b52146e 100644 --- a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java +++ b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java @@ -143,36 +143,38 @@ public void formatResponse(ChallengeWriter cw, ChallengeResponse challenge, Requ @Override public void parseRequest(ChallengeRequest challenge, Response response, Series
httpHeaders) { - if (challenge.getRawValue() != null) { - HeaderReader hr = new HeaderReader(challenge.getRawValue()); - - try { - Parameter param = hr.readParameter(); - - while (param != null) { - try { - if ("realm".equalsIgnoreCase(param.getName())) { - challenge.setRealm(param.getValue()); - } else { - challenge.getParameters().add(param); - } - - if (hr.skipValueSeparator()) { - param = hr.readParameter(); - } else { - param = null; - } - } catch (Exception e) { - Context.getCurrentLogger().log(Level.WARNING, - "Unable to parse the challenge request header parameter", e); - } - } - } catch (Exception e) { - Context.getCurrentLogger().log(Level.WARNING, "Unable to parse the challenge request header parameter", - e); - } - } - } + if (challenge.getRawValue() == null) { + return; + } + + HeaderReader hr = new HeaderReader<>(challenge.getRawValue()); + + try { + Parameter param = hr.readParameter(); + + while (param != null) { + try { + if ("realm".equalsIgnoreCase(param.getName())) { + challenge.setRealm(param.getValue()); + } else { + challenge.getParameters().add(param); + } + + if (hr.skipValueSeparator()) { + param = hr.readParameter(); + } else { + param = null; + } + } catch (Exception e) { + Context.getCurrentLogger().log(Level.WARNING, + "Unable to parse the challenge request header parameter", e); + } + } + } catch (Exception e) { + Context.getCurrentLogger().log(Level.WARNING, "Unable to parse the challenge request header parameter", + e); + } + } @Override public void parseResponse(ChallengeResponse challenge, Request request, Series
httpHeaders) { @@ -204,7 +206,7 @@ public void parseResponse(ChallengeResponse challenge, Request request, Series "Invalid credentials given by client with IP: " + ((request != null) ? request.getClientInfo().getAddress() : "?")); } else { challenge.setIdentifier(credentials.substring(0, separator)); diff --git a/org.restlet.java/org.restlet/src/main/java/org/restlet/security/ChallengeAuthenticator.java b/org.restlet.java/org.restlet/src/main/java/org/restlet/security/ChallengeAuthenticator.java index 1d976c9d96..6051fde1c4 100644 --- a/org.restlet.java/org.restlet/src/main/java/org/restlet/security/ChallengeAuthenticator.java +++ b/org.restlet.java/org.restlet/src/main/java/org/restlet/security/ChallengeAuthenticator.java @@ -112,76 +112,97 @@ protected boolean authenticate(Request request, Response response) { boolean result = false; boolean loggable = request.isLoggable() && getLogger().isLoggable(Level.FINE); - if (getVerifier() != null) { - switch (getVerifier().verify(request, response)) { - case Verifier.RESULT_VALID: - // Valid credentials provided - result = true; - - if (loggable) { - ChallengeResponse challengeResponse = request.getChallengeResponse(); - - if (challengeResponse != null) { - getLogger().fine("Authentication succeeded. Valid credentials provided for identifier: " - + request.getChallengeResponse().getIdentifier() + "."); - } else { - getLogger().fine("Authentication succeeded. Valid credentials provided."); - } - } + if (getVerifier() == null) { + getLogger().warning("Authentication failed. No verifier provided."); + response.setStatus(Status.SERVER_ERROR_INTERNAL, "Authentication failed. No verifier provided."); + } else { + switch (getVerifier().verify(request, response)) { + case Verifier.RESULT_VALID: + // Valid credentials provided + result = validCredentials(request, loggable); break; - case Verifier.RESULT_MISSING: - // No credentials provided - if (loggable) { - getLogger().fine("Authentication failed. No credentials provided."); - } - - if (!isOptional()) { - challenge(response, false); - } + case Verifier.RESULT_MISSING: + // No credentials provided + noCredentialsProvided(response, loggable); break; - case Verifier.RESULT_INVALID: - // Invalid credentials provided - if (loggable) { - getLogger().fine("Authentication failed. Invalid credentials provided."); - } - - if (!isOptional()) { - if (isRechallenging()) { - challenge(response, false); - } else { - forbid(response); - } - } + case Verifier.RESULT_INVALID: + // Invalid credentials provided + invalidCredentialsProvided(response, loggable); break; - case Verifier.RESULT_STALE: - if (loggable) { - getLogger().fine("Authentication failed. Stale credentials provided."); - } - - if (!isOptional()) { - challenge(response, true); - } + case Verifier.RESULT_STALE: + staleAuthentication(response, loggable); break; - case Verifier.RESULT_UNKNOWN: - if (loggable) { - getLogger().fine("Authentication failed. Identifier is unknown."); - } - - if (!isOptional()) { - if (isRechallenging()) { - challenge(response, false); - } else { - forbid(response); - } - } + case Verifier.RESULT_UNKNOWN: + unknownIdentifier(response, loggable); break; + default: + throw new IllegalStateException("Unexpected value: " + getVerifier().verify(request, response)); +} + } + + return result; + } + + private void unknownIdentifier(Response response, boolean loggable) { + if (loggable) { + getLogger().fine("Authentication failed. Identifier is unknown."); + } + + if (!isOptional()) { + if (isRechallenging()) { + challenge(response, false); + } else { + forbid(response); } - } else { - getLogger().warning("Authentication failed. No verifier provided."); - response.setStatus(Status.SERVER_ERROR_INTERNAL, "Authentication failed. No verifier provided."); + } + } + + private void staleAuthentication(Response response, boolean loggable) { + if (loggable) { + getLogger().fine("Authentication failed. Stale credentials provided."); } - return result; + if (!isOptional()) { + challenge(response, true); + } + } + + private void invalidCredentialsProvided(Response response, boolean loggable) { + if (loggable) { + getLogger().fine("Authentication failed. Invalid credentials provided."); + } + + if (!isOptional()) { + if (isRechallenging()) { + challenge(response, false); + } else { + forbid(response); + } + } + } + + private void noCredentialsProvided(Response response, boolean loggable) { + if (loggable) { + getLogger().fine("Authentication failed. No credentials provided."); + } + + if (!isOptional()) { + challenge(response, false); + } + } + + private boolean validCredentials(Request request, boolean loggable) { + if (loggable) { + ChallengeResponse challengeResponse = request.getChallengeResponse(); + + if (challengeResponse != null) { + getLogger().fine("Authentication succeeded. Valid credentials provided for identifier: " + + request.getChallengeResponse().getIdentifier() + "."); + } else { + getLogger().fine("Authentication succeeded. Valid credentials provided."); + } + } + return true; } /** @@ -232,7 +253,7 @@ public void forbid(Response response) { if (loggable) { getLogger().log(Level.FINE, - "Authentication or authorization failed for this URI: " + response.getRequest().getResourceRef()); + () -> "Authentication or authorization failed for this URI: " + response.getRequest().getResourceRef()); } response.setStatus(Status.CLIENT_ERROR_FORBIDDEN); diff --git a/org.restlet.java/org.restlet/src/test/java/org/restlet/security/HttpBasicTestCase.java b/org.restlet.java/org.restlet/src/test/java/org/restlet/security/HttpBasicTestCase.java index a84adb66c3..aa906141eb 100644 --- a/org.restlet.java/org.restlet/src/test/java/org/restlet/security/HttpBasicTestCase.java +++ b/org.restlet.java/org.restlet/src/test/java/org/restlet/security/HttpBasicTestCase.java @@ -12,6 +12,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.params.provider.Arguments.arguments; +import java.io.IOException; import java.util.Arrays; import java.util.stream.Stream; @@ -68,14 +69,10 @@ public TestVerifier() { @Override public int verify(String identifier, char[] inputSecret) { - // NOTE: Allocating Strings are not really secure treatment of passwords - String almostSecret = new String(inputSecret); - try { return super.verify(identifier, inputSecret); } finally { - // Clear secret from memory as soon as possible (This is better - // treatment, but useless due to our almostSecret copy) + // Clear secret from memory as soon as possible Arrays.fill(inputSecret, '\000'); } } @@ -135,14 +132,14 @@ class TestHttpBasicServer { private Client client; @Test - public void HttpBasicNone() throws Exception { + void HttpBasicNone() { final Response response = client.handle(request); assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus()); } @ParameterizedTest @MethodSource("org.restlet.security.HttpBasicTestCase#invalidCredentials") - void testInvalidCredentials(final String login, final String password) throws Exception { + void testInvalidCredentials(final String login, final String password) { ChallengeResponse authentication = new ChallengeResponse(ChallengeScheme.HTTP_BASIC, login, password); request.setChallengeResponse(authentication); @@ -152,7 +149,7 @@ void testInvalidCredentials(final String login, final String password) throws Ex @ParameterizedTest @MethodSource("org.restlet.security.HttpBasicTestCase#validCredentials") - void testValidCredentials(final String login, final String password) throws Exception { + void testValidCredentials(final String login, final String password) throws IOException { ChallengeResponse authentication = new ChallengeResponse(ChallengeScheme.HTTP_BASIC, login, password); request.setChallengeResponse(authentication); @@ -192,7 +189,7 @@ void testChallengeAdvertisesUtf8() { } @BeforeEach - public void makeServer() throws Exception { + void makeServer() throws Exception { final String REALM = HttpBasicTestCase.class.getSimpleName(); this.component = new Component(); final Server server = this.component.getServers().add(Protocol.HTTP, 0); @@ -215,7 +212,7 @@ public Restlet createInboundRoot() { } @AfterEach - public void cleanup() throws Exception { + void cleanup() throws Exception { System.clearProperty(HttpBasicHelper.UTF8_PROPERTY); client.stop(); if (this.component.isStarted()) {