/*
* 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.catalina.authenticator;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.servlet.http.HttpServletResponse;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
import org.apache.catalina.Context;
import org.apache.catalina.session.ManagerBase;
import org.apache.catalina.startup.TesterServlet;
import org.apache.catalina.startup.Tomcat;
import org.apache.catalina.startup.TomcatBaseTest;
import org.apache.tomcat.util.buf.ByteChunk;
import org.apache.tomcat.util.codec.binary.Base64;
import org.apache.tomcat.util.descriptor.web.LoginConfig;
import org.apache.tomcat.util.descriptor.web.SecurityCollection;
import org.apache.tomcat.util.descriptor.web.SecurityConstraint;
/**
* Test BasicAuthenticator and NonLoginAuthenticator when a
* SingleSignOn Valve is not active.
*
* <p>
* In the absence of SSO support, these two authenticator classes
* both have quite simple behaviour. By testing them together, we
* can make sure they operate independently and confirm that no
* SSO logic has been accidentally triggered.
*
* <p>
* r1495169 refactored BasicAuthenticator by creating an inner class
* called BasicCredentials. All edge cases associated with strangely
* encoded Base64 credentials are tested thoroughly by TestBasicAuthParser.
* Therefore, TestNonLoginAndBasicAuthenticator only needs to examine
* a sufficient set of test cases to verify the interface between
* BasicAuthenticator and BasicCredentials, which it does by running
* each test under a separate tomcat instance.
*/
public class TestNonLoginAndBasicAuthenticator extends TomcatBaseTest {
protected static final boolean USE_COOKIES = true;
protected static final boolean NO_COOKIES = !USE_COOKIES;
private static final String USER = "user";
private static final String PWD = "pwd";
private static final String ROLE = "role";
private static final String NICE_METHOD = "Basic";
private static final String HTTP_PREFIX = "http://localhost:";
private static final String CONTEXT_PATH_NOLOGIN = "/nologin";
private static final String CONTEXT_PATH_LOGIN = "/login";
private static final String URI_PROTECTED = "/protected";
private static final String URI_PUBLIC = "/anyoneCanAccess";
private static final int SHORT_SESSION_TIMEOUT_SECS = 1;
private static final int MANAGER_SCAN_INTERVAL_SECS = 2;
private static final int MANAGER_EXPIRE_SESSIONS_FAST = 1;
private static final int EXTRA_DELAY_SECS = 5;
private static final long TIMEOUT_DELAY_MSECS =
((SHORT_SESSION_TIMEOUT_SECS +
(MANAGER_SCAN_INTERVAL_SECS * MANAGER_EXPIRE_SESSIONS_FAST) +
EXTRA_DELAY_SECS) * 1000);
private static final String CLIENT_AUTH_HEADER = "authorization";
private static final String SERVER_AUTH_HEADER = "WWW-Authenticate";
private static final String SERVER_COOKIE_HEADER = "Set-Cookie";
private static final String CLIENT_COOKIE_HEADER = "Cookie";
private static final BasicCredentials NO_CREDENTIALS = null;
private static final BasicCredentials GOOD_CREDENTIALS =
new BasicCredentials(NICE_METHOD, USER, PWD);
private static final BasicCredentials STRANGE_CREDENTIALS =
new BasicCredentials("bAsIc", USER, PWD);
private static final BasicCredentials BAD_CREDENTIALS =
new BasicCredentials(NICE_METHOD, USER, "wrong");
private static final BasicCredentials BAD_METHOD =
new BasicCredentials("BadMethod", USER, PWD);
private Tomcat tomcat;
private Context basicContext;
private Context nonloginContext;
private List<String> cookies;
/*
* Try to access an unprotected resource in a webapp that
* does not have a login method defined.
* This should be permitted.
*/
@Test
public void testAcceptPublicNonLogin() throws Exception {
doTestNonLogin(CONTEXT_PATH_NOLOGIN + URI_PUBLIC, NO_COOKIES,
HttpServletResponse.SC_OK);
}
/*
* Try to access a protected resource in a webapp that
* does not have a login method defined.
* This should be rejected with SC_FORBIDDEN 403 status.
*/
@Test
public void testRejectProtectedNonLogin() throws Exception {
doTestNonLogin(CONTEXT_PATH_NOLOGIN + URI_PROTECTED, NO_COOKIES,
HttpServletResponse.SC_FORBIDDEN);
}
/*
* Try to access an unprotected resource in a webapp that
* has a BASIC login method defined.
* This should be permitted without a challenge.
*/
@Test
public void testAcceptPublicBasic() throws Exception {
doTestBasic(CONTEXT_PATH_LOGIN + URI_PUBLIC, NO_CREDENTIALS,
NO_COOKIES, HttpServletResponse.SC_OK);
}
/*
* Try to access a protected resource in a webapp that
* has a BASIC login method defined. The access will be
* challenged with 401 SC_UNAUTHORIZED, and then be permitted
* once authenticated.
*/
@Test
public void testAcceptProtectedBasic() throws Exception {
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS,
NO_COOKIES, HttpServletResponse.SC_UNAUTHORIZED);
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, GOOD_CREDENTIALS,
NO_COOKIES, HttpServletResponse.SC_OK);
}
/*
* This is the same as testAcceptProtectedBasic (above), except
* using an invalid password.
*/
@Test
public void testAuthMethodBadCredentials() throws Exception {
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS,
NO_COOKIES, HttpServletResponse.SC_UNAUTHORIZED);
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, BAD_CREDENTIALS,
NO_COOKIES, HttpServletResponse.SC_UNAUTHORIZED);
}
/*
* This is the same as testAcceptProtectedBasic (above), except
* to verify the server follows RFC2617 by treating the auth-scheme
* token as case-insensitive.
*/
@Test
public void testAuthMethodCaseBasic() throws Exception {
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS,
NO_COOKIES, HttpServletResponse.SC_UNAUTHORIZED);
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, STRANGE_CREDENTIALS,
NO_COOKIES, HttpServletResponse.SC_OK);
}
/*
* This is the same as testAcceptProtectedBasic (above), except
* using an invalid authentication method.
*
* Note: the container ensures the Basic login method is called.
* BasicAuthenticator does not find the expected authentication
* header method, and so does not extract any credentials.
*
* The request is rejected with 401 SC_UNAUTHORIZED status. RFC2616
* says the response body should identify the auth-schemes that are
* acceptable for the container.
*/
@Test
public void testAuthMethodBadMethod() throws Exception {
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS,
NO_COOKIES, HttpServletResponse.SC_UNAUTHORIZED);
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, BAD_METHOD,
NO_COOKIES, HttpServletResponse.SC_UNAUTHORIZED);
}
/*
* The default behaviour of BASIC authentication does NOT create
* a session on the server. Verify that the client is required to
* send a valid authenticate header with every request to access
* protected resources.
*/
@Test
public void testBasicLoginWithoutSession() throws Exception {
// this section is identical to testAuthMethodCaseBasic
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS,
NO_COOKIES, HttpServletResponse.SC_UNAUTHORIZED);
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, GOOD_CREDENTIALS,
NO_COOKIES, HttpServletResponse.SC_OK);
// next, try to access the protected resource while not providing
// credentials. This confirms the server has not retained any state
// data which might allow it to authenticate the client. Expect
// to be challenged with 401 SC_UNAUTHORIZED.
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS,
NO_COOKIES, HttpServletResponse.SC_UNAUTHORIZED);
// finally, provide credentials to confirm the resource
// can still be accessed with an authentication header.
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, GOOD_CREDENTIALS,
NO_COOKIES, HttpServletResponse.SC_OK);
}
/*
* Test the optional behaviour of BASIC authentication to create
* a session on the server. The server will return a session cookie.
*
* 1. try to access a protected resource without credentials, so
* get Unauthorized status.
* 2. try to access a protected resource when providing credentials,
* so get OK status and a server session cookie.
* 3. access the protected resource once more using a session cookie.
* 4. repeat using the session cookie.
*
* Note: The FormAuthenticator is a two-step process and is protected
* from session fixation attacks by the default AuthenticatorBase
* changeSessionIdOnAuthentication setting of true. However,
* BasicAuthenticator is a one-step process and so the
* AuthenticatorBase does not reissue the sessionId.
*/
@Test
public void testBasicLoginSessionPersistence() throws Exception {
setAlwaysUseSession();
// this section is identical to testAuthMethodCaseBasic
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS,
NO_COOKIES, HttpServletResponse.SC_UNAUTHORIZED);
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, GOOD_CREDENTIALS,
NO_COOKIES, HttpServletResponse.SC_OK);
// confirm the session is not recognised by the server alone
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS,
NO_COOKIES, HttpServletResponse.SC_UNAUTHORIZED);
// now provide the harvested session cookie for authentication
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS,
USE_COOKIES, HttpServletResponse.SC_OK);
// finally, do it again with the cookie to be sure
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS,
USE_COOKIES, HttpServletResponse.SC_OK);
}
/*
* Verify the timeout mechanism works for BASIC sessions. This test
* follows the flow of testBasicLoginSessionPersistence (above).
*/
@Test
public void testBasicLoginSessionTimeout() throws Exception {
setAlwaysUseSession();
setRapidSessionTimeout();
// this section is identical to testAuthMethodCaseBasic
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS,
NO_COOKIES, HttpServletResponse.SC_UNAUTHORIZED);
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, GOOD_CREDENTIALS,
NO_COOKIES, HttpServletResponse.SC_OK);
// now provide the harvested session cookie for authentication
List<String> originalCookies = cookies;
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS,
USE_COOKIES, HttpServletResponse.SC_OK);
// Force session to expire one second from now
forceSessionMaxInactiveInterval(
(Context) getTomcatInstance().getHost().findChild(CONTEXT_PATH_LOGIN),
SHORT_SESSION_TIMEOUT_SECS);
// allow the session to time out and lose authentication
Thread.sleep(TIMEOUT_DELAY_MSECS);
// provide the harvested session cookie for authentication
// to confirm it has expired
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS,
USE_COOKIES, HttpServletResponse.SC_UNAUTHORIZED);
// finally, do BASIC reauthentication and get another session
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS,
NO_COOKIES, HttpServletResponse.SC_UNAUTHORIZED);
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, GOOD_CREDENTIALS,
NO_COOKIES, HttpServletResponse.SC_OK);
// slightly paranoid verification
boolean sameCookies = originalCookies.equals(cookies);
assertTrue(!sameCookies);
}
/*
* Logon to access a protected resource in a webapp that uses
* BASIC authentication. Then try to access a protected resource
* in a different webapp which does not have a login method.
* This should be rejected with SC_FORBIDDEN 403 status, confirming
* there has been no cross-authentication between the webapps.
*/
@Test
public void testBasicLoginRejectProtected() throws Exception {
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS,
NO_COOKIES, HttpServletResponse.SC_UNAUTHORIZED);
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, GOOD_CREDENTIALS,
NO_COOKIES, HttpServletResponse.SC_OK);
doTestNonLogin(CONTEXT_PATH_NOLOGIN + URI_PROTECTED,
NO_COOKIES, HttpServletResponse.SC_FORBIDDEN);
}
/*
* Try to use the session cookie from the BASIC webapp to request
* access to the webapp that does not have a login method. (This
* is equivalent to Single Signon, but without the Valve.)
*
* Verify there is no cross-authentication when using similar logic
* to testBasicLoginRejectProtected (above).
*
* This should be rejected with SC_FORBIDDEN 403 status.
*/
@Test
public void testBasicLoginRejectProtectedWithSession() throws Exception {
setAlwaysUseSession();
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS,
NO_COOKIES, HttpServletResponse.SC_UNAUTHORIZED);
doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, GOOD_CREDENTIALS,
NO_COOKIES, HttpServletResponse.SC_OK);
// use the session cookie harvested with the other webapp
doTestNonLogin(CONTEXT_PATH_NOLOGIN + URI_PROTECTED,
USE_COOKIES, HttpServletResponse.SC_FORBIDDEN);
}
private void doTestNonLogin(String uri, boolean useCookie,
int expectedRC) throws Exception {
Map<String,List<String>> reqHeaders = new HashMap<>();
Map<String,List<String>> respHeaders = new HashMap<>();
if (useCookie) {
addCookies(reqHeaders);
}
ByteChunk bc = new ByteChunk();
int rc = getUrl(HTTP_PREFIX + getPort() + uri, bc, reqHeaders,
respHeaders);
if (expectedRC != HttpServletResponse.SC_OK) {
assertEquals(expectedRC, rc);
assertTrue(bc.getLength() > 0);
}
else {
assertEquals("OK", bc.toString());
}
}
private void doTestBasic(String uri, BasicCredentials credentials,
boolean useCookie, int expectedRC) throws Exception {
Map<String,List<String>> reqHeaders = new HashMap<>();
Map<String,List<String>> respHeaders = new HashMap<>();
if (useCookie) {
addCookies(reqHeaders);
}
else {
if (credentials != null) {
List<String> auth = new ArrayList<>();
auth.add(credentials.getCredentials());
reqHeaders.put(CLIENT_AUTH_HEADER, auth);
}
}
ByteChunk bc = new ByteChunk();
int rc = getUrl(HTTP_PREFIX + getPort() + uri, bc, reqHeaders,
respHeaders);
if (expectedRC != HttpServletResponse.SC_OK) {
assertEquals(expectedRC, rc);
assertTrue(bc.getLength() > 0);
if (expectedRC == HttpServletResponse.SC_UNAUTHORIZED) {
// The server should identify the acceptable method(s)
boolean methodFound = false;
List<String> authHeaders = respHeaders.get(SERVER_AUTH_HEADER);
for (String authHeader : authHeaders) {
if (authHeader.contains(NICE_METHOD)) {
methodFound = true;
break;
}
}
assertTrue(methodFound);
}
}
else {
assertEquals("OK", bc.toString());
List<String> newCookies = respHeaders.get(SERVER_COOKIE_HEADER);
if (newCookies != null) {
// harvest cookies whenever the server sends some new ones
saveCookies(respHeaders);
}
}
}
/*
* setup two webapps for every test
*
* note: the super class tearDown method will stop tomcat
*/
@Override
public void setUp() throws Exception {
super.setUp();
// create a tomcat server using the default in-memory Realm
tomcat = getTomcatInstance();
// add the test user and role to the Realm
tomcat.addUser(USER, PWD);
tomcat.addRole(USER, ROLE);
// setup both NonLogin and Login webapps
setUpNonLogin();
setUpLogin();
tomcat.start();
}
private void setUpNonLogin() throws Exception {
// Must have a real docBase for webapps - just use temp
nonloginContext = tomcat.addContext(CONTEXT_PATH_NOLOGIN,
System.getProperty("java.io.tmpdir"));
// Add protected servlet to the context
Tomcat.addServlet(nonloginContext, "TesterServlet1", new TesterServlet());
nonloginContext.addServletMappingDecoded(URI_PROTECTED, "TesterServlet1");
SecurityCollection collection1 = new SecurityCollection();
collection1.addPatternDecoded(URI_PROTECTED);
SecurityConstraint sc1 = new SecurityConstraint();
sc1.addAuthRole(ROLE);
sc1.addCollection(collection1);
nonloginContext.addConstraint(sc1);
// Add unprotected servlet to the context
Tomcat.addServlet(nonloginContext, "TesterServlet2", new TesterServlet());
nonloginContext.addServletMappingDecoded(URI_PUBLIC, "TesterServlet2");
SecurityCollection collection2 = new SecurityCollection();
collection2.addPatternDecoded(URI_PUBLIC);
SecurityConstraint sc2 = new SecurityConstraint();
// do not add a role - which signals access permitted without one
sc2.addCollection(collection2);
nonloginContext.addConstraint(sc2);
// Configure the authenticator and inherit the Realm from Engine
LoginConfig lc = new LoginConfig();
lc.setAuthMethod("NONE");
nonloginContext.setLoginConfig(lc);
AuthenticatorBase nonloginAuthenticator = new NonLoginAuthenticator();
nonloginContext.getPipeline().addValve(nonloginAuthenticator);
}
private void setUpLogin() throws Exception {
// Must have a real docBase for webapps - just use temp
basicContext = tomcat.addContext(CONTEXT_PATH_LOGIN,
System.getProperty("java.io.tmpdir"));
// Add protected servlet to the context
Tomcat.addServlet(basicContext, "TesterServlet3", new TesterServlet());
basicContext.addServletMappingDecoded(URI_PROTECTED, "TesterServlet3");
SecurityCollection collection = new SecurityCollection();
collection.addPatternDecoded(URI_PROTECTED);
SecurityConstraint sc = new SecurityConstraint();
sc.addAuthRole(ROLE);
sc.addCollection(collection);
basicContext.addConstraint(sc);
// Add unprotected servlet to the context
Tomcat.addServlet(basicContext, "TesterServlet4", new TesterServlet());
basicContext.addServletMappingDecoded(URI_PUBLIC, "TesterServlet4");
SecurityCollection collection2 = new SecurityCollection();
collection2.addPatternDecoded(URI_PUBLIC);
SecurityConstraint sc2 = new SecurityConstraint();
// do not add a role - which signals access permitted without one
sc2.addCollection(collection2);
basicContext.addConstraint(sc2);
// Configure the authenticator and inherit the Realm from Engine
LoginConfig lc = new LoginConfig();
lc.setAuthMethod("BASIC");
basicContext.setLoginConfig(lc);
AuthenticatorBase basicAuthenticator = new BasicAuthenticator();
basicContext.getPipeline().addValve(basicAuthenticator);
}
/*
* Force non-default behaviour for both Authenticators
*/
private void setAlwaysUseSession() {
((AuthenticatorBase)basicContext.getAuthenticator())
.setAlwaysUseSession(true);
((AuthenticatorBase)nonloginContext.getAuthenticator())
.setAlwaysUseSession(true);
}
/*
* Force rapid timeout scanning for the Basic Authentication webapp
* The StandardManager default service cycle time is 10 seconds,
* with a session expiry scan every 6 cycles.
*/
private void setRapidSessionTimeout() {
basicContext.getParent().getParent().setBackgroundProcessorDelay(
MANAGER_SCAN_INTERVAL_SECS);
((ManagerBase) basicContext.getManager())
.setProcessExpiresFrequency(MANAGER_EXPIRE_SESSIONS_FAST);
}
/*
* Encapsulate the logic to generate an HTTP header
* for BASIC Authentication.
* Note: only used internally, so no need to validate arguments.
*/
private static final class BasicCredentials {
private final String method;
private final String username;
private final String password;
private final String credentials;
private BasicCredentials(String aMethod,
String aUsername, String aPassword) {
method = aMethod;
username = aUsername;
password = aPassword;
String userCredentials = username + ":" + password;
byte[] credentialsBytes =
userCredentials.getBytes(StandardCharsets.ISO_8859_1);
String base64auth = Base64.encodeBase64String(credentialsBytes);
credentials= method + " " + base64auth;
}
private String getCredentials() {
return credentials;
}
}
/*
* extract and save the server cookies from the incoming response
*/
protected void saveCookies(Map<String,List<String>> respHeaders) {
// we only save the Cookie values, not header prefix
List<String> cookieHeaders = respHeaders.get(SERVER_COOKIE_HEADER);
if (cookieHeaders == null) {
cookies = null;
} else {
cookies = new ArrayList<>(cookieHeaders.size());
for (String cookieHeader : cookieHeaders) {
cookies.add(cookieHeader.substring(0, cookieHeader.indexOf(';')));
}
}
}
/*
* add all saved cookies to the outgoing request
*/
protected void addCookies(Map<String,List<String>> reqHeaders) {
if ((cookies != null) && (cookies.size() > 0)) {
StringBuilder cookieHeader = new StringBuilder();
boolean first = true;
for (String cookie : cookies) {
if (!first) {
cookieHeader.append(';');
} else {
first = false;
}
cookieHeader.append(cookie);
}
List<String> cookieHeaderList = new ArrayList<>(1);
cookieHeaderList.add(cookieHeader.toString());
reqHeaders.put(CLIENT_COOKIE_HEADER, cookieHeaderList);
}
}
}