namalzi

← 글 목록

Tomcat 소스 파헤치기 14: 인증과 Realm — security-constraint는 어떻게 지켜지나

Tomcat · 게시

web.xml 의 security-constraint 와 login-config, server.xml 의 Realm, tomcat-users.xml 이 소스에서 어떻게 이어지는지 따라갑니다. 웹앱이 시작될 때 Authenticator 밸브가 끼워지는 자리, AuthenticatorBase.invoke() 의 검사 순서, URL 에 걸린 제약을 고르는 네 번의 검색, BASIC 과 FORM 의 차이, Realm 과 CredentialHandler 의 역할을 실제 소스와 401·403·303 응답으로 확인합니다.

이 글에서 다루는 것

"Tomcat 소스 파헤치기" 연재의 열네 번째 글입니다. 13편에서는 JSP 가 서블릿으로 바뀌는 과정을 봤습니다. 이번에는 요청이 서블릿에 닿기 전에 "이 사람을 들여보내도 되는가"를 정하는 곳으로 갑니다.

web.xml 에 이런 설정을 적어 본 적이 있을 겁니다.

  <security-constraint>
    <web-resource-collection>
      <web-resource-name>staff area</web-resource-name>
      <url-pattern>/staff/*</url-pattern>
    </web-resource-collection>
    <auth-constraint>
      <role-name>staff</role-name>
    </auth-constraint>
  </security-constraint>

  <login-config>
    <auth-method>BASIC</auth-method>
    <realm-name>u14 lab</realm-name>
  </login-config>

그리고 conf/tomcat-users.xml 에 <user username="alice" password="secret" roles="staff"/> 를 넣으면, 브라우저가 아이디와 비밀번호를 묻고 alice 로 들어가면 페이지가 보입니다. 서블릿 코드에는 한 줄도 손대지 않았는데 말입니다. 이 설정이 소스의 어디에서 어떻게 지켜지는지 따라갑니다. 답하려는 질문은 네 가지입니다.

항목 내용
대상 Apache Tomcat
버전 12.0.0-M1 (main)
커밋 17aa1769b0 (2026-10-23)
주로 읽는 클래스 startup/ContextConfig, authenticator/AuthenticatorBase·BasicAuthenticator·FormAuthenticator, realm/RealmBase·UserDatabaseRealm·LockOutRealm·MessageDigestCredentialHandler

글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다.

authenticator(하위 패키지 jaspic 포함)와 realm 두 패키지만 해도 Java 파일이 서른 개가 넘습니다(DIGEST, 클라이언트 인증서, SPNEGO, JASPIC, 싱글 사인온, LDAP, DB …). 이 글은 처음 보는 분이 따라올 수 있도록 BASIC·FORM + tomcat-users.xml 이라는 가장 흔한 조합으로 큰 줄기만 봅니다. 빼놓은 것은 글 끝의 "더 깊이 들어가면"에 이름과 위치만 적어 두었습니다.

먼저 알아 둘 말

인증(authentication)은 "당신은 누구인가"를 확인하는 일이고, 권한 부여(authorization, 이 글에서는 줄여서 "권한")는 "그 사람이 이것을 해도 되는가"를 정하는 일입니다. httpd 가 이 둘을 서로 다른 모듈로 나눈 이야기는 httpd 13편에 있습니다. Tomcat 에서 인증은 Authenticator, 권한 판정은 Realm 이 맡습니다(정확히는 Realm 이 둘 다 조금씩 하는데, 3절과 5절에서 봅니다).

밸브(Valve)는 Engine·Host·Context·Wrapper 컨테이너마다 걸린 처리 사슬(파이프라인)의 고리 하나입니다. 밸브는 invoke(request, response) 에서 할 일을 하고 getNext().invoke(...) 로 다음 밸브에 넘기거나, 넘기지 않고 응답을 끝낼 수 있습니다. 파이프라인과 밸브는 8편에서 봤습니다. 이 글의 주인공 Authenticator 밸브도 그런 밸브 하나입니다.

Realm(렐름)은 "사용자 정보가 있는 곳"을 Tomcat 이 감싼 객체입니다. 아이디와 비밀번호를 받아 맞으면 사용자 객체를 돌려주고, 그 사용자가 어떤 역할(role)을 가졌는지 알려 줍니다. server.xml 의 <Realm className="…"> 한 줄이 Realm 객체 하나가 됩니다. 사용자 정보가 파일에 있으면 UserDatabaseRealm·MemoryRealm, DB 에 있으면 DataSourceRealm, LDAP 에 있으면 JNDIRealm 을 씁니다.

Principal(프린시펄)은 "인증을 통과한 사용자"를 나타내는 Java 객체입니다(java.security.Principal). Tomcat 은 이름과 역할 목록을 가진 GenericPrincipal 을 씁니다. 서블릿에서 request.getRemoteUser(), request.isUserInRole("staff") 가 돌려주는 값이 여기서 옵니다.

security-constraint 는 web.xml 에서 "이 URL 들은 이렇게 지켜라"를 적는 요소입니다. 세 부분으로 되어 있습니다.

하위 요소 뜻 소스의 자리
<web-resource-collection> 어떤 URL 패턴, 어떤 HTTP 메서드에 거는가 SecurityCollection
<auth-constraint> 어떤 역할이어야 들어오는가 SecurityConstraint.authRoles
<user-data-constraint> HTTPS 로만 받을 것인가 (CONFIDENTIAL) SecurityConstraint.userConstraint

web.xml 이 이런 객체로 바뀌는 과정(ContextConfig, WebXml)은 10편에서 봤습니다.

1. 누가 Authenticator 밸브를 끼워 넣나

web.xml 에는 "BASIC 으로 인증하라"고만 적혀 있지, 어떤 클래스를 쓰라는 말은 없습니다. 그 다리를 놓는 것이 ContextConfig.authenticatorConfig() 입니다. 웹앱이 시작될 때 web.xml 을 다 읽고 나서(configureStart(), 1055–1058행) 한 번 불립니다.

        LoginConfig loginConfig = context.getLoginConfig();
        if (loginConfig == null) {
            // Need an authenticator to support HttpServletRequest.login()
            loginConfig = DUMMY_LOGIN_CONFIG;
            context.setLoginConfig(loginConfig);
        }

        // Has an authenticator been configured already?
        if (context.getAuthenticator() != null) {
            return;
        }

        // Has a Realm been configured for us to authenticate against?
        if (context.getRealm() == null) {
            log.error(sm.getString("contextConfig.missingRealm"));
            ok = false;
            return;
        }
        // ...
            // Identify the class name of the Valve we should configure
            String authenticatorName = authenticators.getProperty(loginConfig.getAuthMethod());
        // ...
        if (authenticator != null) {
            Pipeline pipeline = context.getPipeline();
            if (pipeline != null) {
                pipeline.addValve(authenticator);

java/org/apache/catalina/startup/ContextConfig.java 341–398행

하는 일은 세 가지입니다.

  1. <auth-method> 이름으로 밸브 클래스를 찾습니다. 이름과 클래스의 짝은 Authenticators.properties 라는 작은 파일에 있습니다(154–166행에서 읽습니다).

    <auth-method> 밸브 클래스
    BASIC BasicAuthenticator
    FORM FormAuthenticator
    DIGEST DigestAuthenticator
    CLIENT-CERT SSLAuthenticator
    SPNEGO SpnegoAuthenticator
    NONE NonLoginAuthenticator

    java/org/apache/catalina/startup/Authenticators.properties 16–21행

  2. 찾은 밸브를 Context 의 파이프라인에 넣습니다(pipeline.addValve()). 8편에서 본 대로 addValve() 는 기본 밸브(StandardContextValve) 앞에 붙이므로, Authenticator 는 StandardContextValve 보다 먼저 불립니다.

  3. <login-config> 가 아예 없어도 밸브를 넣습니다. 이름이 NONE 인 가짜 설정(DUMMY_LOGIN_CONFIG, 145행)을 대신 쓰므로 NonLoginAuthenticator 가 들어갑니다. 주석이 이유를 적어 두었습니다 — 서블릿 코드가 HttpServletRequest.login() 을 부를 수 있어야 하기 때문입니다. 그래서 Tomcat 의 웹앱에는 사실상 언제나 Authenticator 밸브가 하나 있습니다.

Realm 이 없으면 웹앱 시작이 실패한다는 것(ok = false)도 눈여겨볼 만합니다. 그런데 기본 server.xml 을 쓰지 않는 내장 Tomcat 같은 경우는 어떻게 될까요? context.getRealm() 은 ContainerBase.getRealm() 으로, 내게 Realm 이 없으면 부모 컨테이너에게 묻습니다(479–494행). Context → Host → Engine 으로 올라가고, 맨 위의 StandardEngine.getRealm() 은 아무것도 설정되지 않았으면 아무도 통과시키지 않는 NullRealm 을 만들어 둡니다(92–100행). 컨테이너의 부모·자식 사슬은 2편에서 봤습니다.

요청 처리 길에서 Authenticator 밸브의 자리. CoyoteAdapter.service가 Engine 파이프라인, Host 파이프라인(AccessLogValve, ErrorReportValve, StandardHostValve)을 거쳐 Context 파이프라인의 Authenticator 밸브(AuthenticatorBase.invoke)를 부른다. 이 밸브는 웹앱이 시작될 때 ContextConfig.authenticatorConfig가 login-config의 auth-method로 클래스를 골라 addValve로 넣어 둔 것이다. 통과하면 StandardContextValve, StandardWrapperValve, 필터 체인, 서블릿으로 가고, 401이나 403이나 로그인 페이지면 여기서 응답을 만들고 돌아가 요청한 서블릿까지 가지 않는다. 파란 상자 둘이 이 글의 주인공입니다. 점선은 요청마다가 아니라 웹앱이 시작될 때 한 번 일어나는 일입니다.

예제 1: 서블릿에서 본 호출 스택

정말 이 자리에 있는지 확인했습니다. 서블릿 안에서 new Throwable().getStackTrace() 로 호출 스택을 얻어, 밸브와 필터 체인만 골라 바깥에서 안쪽 순서로 찍었습니다. web.xml 은 이 글 머리의 것 그대로이고, 사용자는 alice(역할 staff)와 bob(역할 없음) 둘입니다.

이 예제는 기준 커밋(17aa1769b0)을 그대로 빌드한 Tomcat 12.0.0-M1-dev 로 도커 컨테이너(Eclipse Temurin JDK 21)에서 실행했습니다. 예제 폴더는 ex/u14-basic 입니다.

// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
// 인증을 통과한 요청이 누구로 기록됐는지 찍는다. ?stack 을 붙이면
// 이 서블릿까지 오는 동안 지난 Tomcat 의 밸브들을 호출 스택에서 골라 찍는다.
// ... import 생략 ...
public class WhoAmI extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.setContentType("text/plain");
        PrintWriter out = resp.getWriter();
        out.println("uri=" + req.getRequestURI()
                + " user=" + req.getRemoteUser()
                + " authType=" + req.getAuthType()
                + " inRole(staff)=" + req.isUserInRole("staff"));
        if (req.getParameter("stack") != null) {
            // 스택의 아래(바깥)에서 위(안쪽) 순서로, 밸브와 필터 체인만 고른다
            StackTraceElement[] st = new Throwable().getStackTrace();
            for (int i = st.length - 1; i >= 0; i--) {
                String c = st[i].getClassName();
                if (c.startsWith("org.apache.catalina.core.Standard")
| c.startsWith("org.apache.catalina.authenticator")
| c.startsWith("org.apache.catalina.valves")
| c.equals("org.apache.catalina.connector.CoyoteAdapter")
| c.equals("org.apache.catalina.core.ApplicationFilterChain")
| c.equals(getClass().getName())) {
                    out.println("  at " + c + "." + st[i].getMethodName()
                            + "(" + st[i].getFileName() + ":" + st[i].getLineNumber() + ")");
                }
            }
        }
    }
}

curl -u alice:secret "http://127.0.0.1:8080/basic/staff/hello?stack" 의 결과입니다.

### 7. call stack seen from the servlet (alice)
uri=/basic/staff/hello user=alice authType=BASIC inRole(staff)=true
  at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:347)
  at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:71)
  at org.apache.catalina.valves.AbstractAccessLogValve.invoke(AbstractAccessLogValve.java:777)
  at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:82)
  at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115)
  at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:659)
  at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:77)
  at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:164)
  at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
  at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:132)
  at WhoAmI.doGet(WhoAmI.java:21)

StandardHostValve 가 Context 파이프라인의 첫 밸브를 부르는 줄(StandardHostValve.java 115행)에서 바로 AuthenticatorBase.invoke() 로 들어갔고, 그 659행(통과했으니 다음 밸브를 부르는 getNext().invoke())에서 StandardContextValve 로 넘어갔습니다. 필터 체인(ApplicationFilterChain)은 그보다 한참 안쪽입니다.

헷갈리기 쉬운 곳: 스택에는 BasicAuthenticator 가 보이지 않습니다

파이프라인에 들어간 객체는 BasicAuthenticator 인데 스택에는 AuthenticatorBase.invoke 라고 찍힙니다. invoke() 는 부모 클래스 AuthenticatorBase 에만 있고, BasicAuthenticator 는 그 안에서 불리는 doAuthenticate() 만 구현하기 때문입니다(2절). 스택 트레이스는 객체의 클래스가 아니라 메서드가 선언된 클래스 이름을 적습니다.

헷갈리기 쉬운 곳: 컨테이너의 보안 검사는 모든 필터보다 먼저입니다

Authenticator 는 밸브라서 서블릿 필터보다 바깥에 있습니다. web.xml 의 security-constraint 로 막힌 요청은 필터 하나 거치지 않고 401/403 을 받습니다. 반대로 Spring Security 처럼 필터로 만든 보안은 컨테이너 검사를 통과한 뒤에야 불립니다. 두 가지를 같이 쓰면 이 순서를 기억해 두어야 합니다. 서블릿 필터 체인은 9편에서, 필터를 쓰는 Spring 쪽 이야기는 Spring 20편에서 봤습니다. (Spring Security 의 소스는 이 연재에서 읽지 않았습니다.)

2. AuthenticatorBase.invoke() — 검사의 순서

이제 밸브 안으로 들어갑니다. invoke() 는 AuthenticatorBase 의 498–664행으로, JASPIC(외부 인증 모듈을 꽂는 표준 API)과 CORS 사전 요청 처리를 빼면 순서가 단순합니다.

AuthenticatorBase.invoke의 순서. ① 세션에 저장된 Principal이 있으면 요청에 옮겨 적는다. ② realm.findSecurityConstraints로 이 URL과 메서드에 걸린 제약을 찾고, 없고 인증도 필요 없으면 다음 밸브로 그냥 통과한다. ③ 제약이 있으면 hasUserDataPermission으로 CONFIDENTIAL 제약을 보고 실패하면 리다이렉트나 403. ④ 역할 제약이 있으면 authRequired를 true로 한다. ⑤ authRequired면 Basic, Form이 저마다 구현한 doAuthenticate를 부르고 실패하면 401이나 로그인 페이지. ⑥ realm.hasResourcePermission으로 역할을 판정하고 실패하면 403, 통과하면 다음 밸브인 StandardContextValve로 간다. 파란 상자가 차례로 지나는 검사입니다. 빨간 상자에 닿으면 밸브는 응답을 만들고 다음 밸브를 부르지 않고 돌아갑니다.

앞부분은 이렇습니다.

        boolean authRequired = isContinuationRequired(request);

        Realm realm = this.context.getRealm();
        // Is this request URI subject to a security constraint?
        SecurityConstraint[] constraints = realm.findSecurityConstraints(request, this.context);

        JaspicContextState jaspicContextState = getJaspicContextState();

        // Will be non-null if JASPIC is configured for this context
        if (jaspicContextState != null) {
            authRequired = true;
        }

        if (constraints == null && !context.getPreemptiveAuthentication() && !authRequired) {
            if (log.isTraceEnabled()) {
                log.trace("Not subject to any constraint");
            }
            getNext().invoke(request, response);
            return;
        }

java/org/apache/catalina/authenticator/AuthenticatorBase.java 523–542행

  1. 세션에 남은 로그인 정보를 옮겨 적습니다(504–521행). 앞서 로그인하며 세션에 Principal 을 저장해 두었다면(cache 속성, 기본 true, 164행) 이 요청에도 그 사용자를 적어 둡니다. FORM 로그인이 두 번째 요청부터 비밀번호 없이 통하는 이유가 이것입니다(7절).
  2. 이 URL 에 걸린 제약을 찾습니다. 눈여겨볼 것은 그 일을 Realm 에게 묻는다는 점입니다(realm.findSecurityConstraints()). 3절에서 봅니다. 제약이 없고 인증을 요구할 다른 사정도 없으면(FORM 로그인의 진행 중인 요청 같은 것, isContinuationRequired()) 바로 다음 밸브로 넘깁니다. 보호하지 않는 URL 은 여기서 끝납니다.
  3. 제약이 있으면 캐시를 끕니다(disableCaching(), 667–679행). POST 가 아니면 응답에 Cache-Control: private 을 붙여, 중간의 프록시가 보호된 페이지를 저장해 다른 사람에게 내주지 않게 합니다. 예제 2의 출력에서 실제로 보입니다.
  4. 전송 보장(<user-data-constraint>)을 봅니다(checkUserDataConstraints() → realm.hasUserDataPermission()). CONFIDENTIAL 인데 HTTP 로 왔으면 커넥터의 redirectPort 로 리다이렉트하거나, 그 포트가 없으면 403 입니다(RealmBase.java 1012–1059행).

그다음이 "인증이 필요한가"를 정하는 부분입니다.

        // Since authenticate modifies the response on failure,
        // we have to check for allow-from-all first.
        boolean hasAuthConstraint = false;
        if (constraints != null) {
            hasAuthConstraint = true;
            for (int i = 0; i < constraints.length && hasAuthConstraint; i++) {
                if (constraints[i].getAllRoles() || constraints[i].getAuthenticatedUsers()) {
                    // NO-OP - hasAuthConstraint remains true
                } else if (!constraints[i].getAuthConstraint()) {
                    hasAuthConstraint = false;
                } else {
                    String[] roles = constraints[i].findAuthRoles();
                    if (roles == null || roles.length == 0) {
                        hasAuthConstraint = false;
                    }
                }
            }
        }

        if (!authRequired && hasAuthConstraint) {
            authRequired = true;
        }

java/org/apache/catalina/authenticator/AuthenticatorBase.java 554–575행

주석이 순서의 이유를 말해 줍니다. "인증은 실패하면 응답을 고쳐 버리므로(401 을 써 버리므로), 누구나 들어와도 되는 경우를 먼저 가려야 한다." 걸린 제약 모두에 역할 조건이 있을 때만 인증을 요구합니다. 하나라도 <auth-constraint> 가 없는 제약이 섞여 있으면 인증을 건너뜁니다.

  1. 인증합니다. authRequired 면 doAuthenticateExtended() → doAuthenticate() 를 부릅니다(596–609행). 이것이 BasicAuthenticator·FormAuthenticator 가 저마다 구현하는 추상 메서드입니다(835행). 실패하면 "응답은 Authenticator 가 이미 만들었다"며 그냥 돌아갑니다(625–634행).
  2. 역할을 확인합니다.
        if (constraints != null) {
            if (log.isTraceEnabled()) {
                log.trace("Calling accessControl()");
            }
            if (!realm.hasResourcePermission(request, response, constraints, this.context)) {
                if (log.isDebugEnabled()) {
                    log.debug(sm.getString("authenticator.userPermissionFail",
                            request.getUserPrincipal() == null ? "" : request.getUserPrincipal().getName()));
                }
                /*
                 * ASSERT: AccessControl method has already set the appropriate HTTP status code, so we do not have to
                 * do anything special
                 */
                return;
            }
        }

        // Any and all specified constraints have been satisfied
        if (log.isTraceEnabled()) {
            log.trace("Successfully passed all security constraints");
        }
        getNext().invoke(request, response);

java/org/apache/catalina/authenticator/AuthenticatorBase.java 638–659행

여기서도 판정은 Realm 이 합니다(realm.hasResourcePermission(), 5절). 마지막 줄 659행이 예제 1의 스택에 찍힌 바로 그 줄입니다.

왜 제약 찾기와 역할 판정이 Realm 에 있나

"사용자 정보가 있는 곳"인 Realm 이 URL 패턴 검색까지 하는 것은 처음 보면 어색합니다. 소스로 확인할 수 있는 것은 이것이 Realm 인터페이스에 정해진 메서드라는 사실입니다 — findSecurityConstraints(), hasResourcePermission(), hasRole(), hasUserDataPermission() 이 모두 Realm 에 있습니다(Realm.java 152행, 167행, 181행, 196행). 그래서 Authenticator 는 "어떻게 자격 증명을 받는가"만 알고, "무엇이 보호되고 누가 들어올 수 있는가"는 Realm 을 바꾸면 함께 바꿀 수 있습니다. 실제 구현은 거의 모든 Realm 이 RealmBase 의 것을 그대로 물려받습니다.

3. findSecurityConstraints() — 이 URL 에 걸린 제약은 무엇인가

RealmBase.findSecurityConstraints()(543–773행)는 230줄짜리 메서드인데, 같은 모양의 반복문이 네 번 나옵니다. 웹앱의 제약 전체를 네 번 훑으며 패턴을 다른 방식으로 맞춰 봅니다.

findSecurityConstraints의 네 번 검색. 1차는 url-pattern이 경로와 정확히 같은지 보고, 패턴이 맞았다면 메서드가 안 맞아도 여기서 끝내며 메서드가 맞은 제약만 돌려준다. 맞는 패턴이 없으면 2차로 /* 로 끝나는 경로 접두사 가운데 가장 긴 것을 찾아, 그 패턴에서 메서드도 맞았으면 그 길이의 제약들만 돌려준다. 없거나 메서드가 안 맞으면 3차로 *.jsp 같은 확장자 패턴을 보고, 패턴이 맞았으면 메서드가 맞은 제약만 돌려준다. 그것도 없으면 4차로 기본 패턴 / 를 보고 맞은 것을 돌려주며, 하나도 없으면 null, 곧 제약 없음이다. 빨간 상자는 아래의 "헷갈리기 쉬운 곳"에서 실제로 밟아 본 함정입니다.

순서는 7편에서 본 서블릿 매핑 규칙과 같습니다 — 정확히 일치 → 가장 긴 경로 접두사 → 확장자 → 기본(/). 다른 점은, 매핑은 서블릿 하나를 고르지만 여기서는 같은 단계에서 맞은 제약을 모두 모은다는 것입니다. 첫 단계만 옮겨 보면 이렇습니다.

                for (String pattern : patterns) {
                    // Exact match including special case for the context root.
                    if (uri.equals(pattern) || pattern.isEmpty() && uri.equals("/")) {
                        found = true;
                        if (securityCollection.findMethod(method)) {
                            if (results == null) {
                                results = new ArrayList<>();
                            }
                            results.add(constraints[i]);
                        }
                    }
                }
            }
        }

        if (found) {
            return resultsToArray(results);
        }

java/org/apache/catalina/realm/RealmBase.java 590–607행

found = true 는 패턴이 맞으면 켜지고, 결과 목록에는 메서드까지 맞은 제약만 들어갑니다. 그리고 found 면 바로 돌아갑니다. 두 단계 뒤의 접두사 검색(609–672행)은 조금 다릅니다. 더 긴 패턴이 나오면 모은 것을 비우고(652–658행), found 는 메서드까지 맞았을 때만 켭니다(659–665행). 마지막에 결과가 비었으면 resultsToArray() 가 null 을 돌려주는데(778–783행), invoke() 는 이 null 을 "제약 없음"으로 읽습니다.

예제 2: 겹치는 제약

ex/u14-basic 의 web.xml 에는 머리말의 /staff/* 제약에 더해 두 개를 더 두었습니다.

  <!-- 단, /staff/notice 는 정확히 일치하는 패턴이고 auth-constraint 가 없다 -->
  <security-constraint>
    <web-resource-collection>
      <web-resource-name>notice</web-resource-name>
      <url-pattern>/staff/notice</url-pattern>
    </web-resource-collection>
  </security-constraint>

  <!-- /staff/report 는 정확히 일치하는 패턴인데 POST 에만 건다 -->
  <security-constraint>
    <web-resource-collection>
      <web-resource-name>report (POST only)</web-resource-name>
      <url-pattern>/staff/report</url-pattern>
      <http-method>POST</http-method>
    </web-resource-collection>
    <auth-constraint>
      <role-name>staff</role-name>
    </auth-constraint>
  </security-constraint>

run.sh 는 curl 로 요청하고 상태 줄, 몇 가지 헤더, 본문 첫 줄을 찍습니다( | 로 시작하는 줄이 본문). 아이디·비밀번호 없이 요청했습니다.

### 5. /staff/notice, no credentials (exact pattern, no auth-constraint)
HTTP/1.1 200 
Cache-Control: private
 uri=/basic/staff/notice user=null authType=null inRole(staff)=false
### 5b. GET /staff/report, no credentials (exact pattern for POST only)
SEVERE [main] org.apache.tomcat.util.descriptor.web.SecurityConstraint.findUncoveredHttpMethods For security constraints with URL pattern [/staff/report] only the HTTP methods [POST] are covered. All other methods are uncovered.
HTTP/1.1 200 
 uri=/basic/staff/report user=null authType=null inRole(staff)=false
### 5c. POST /staff/report, no credentials
HTTP/1.1 401 
WWW-Authenticate: Basic realm="u14 lab", charset=UTF-8

헷갈리기 쉬운 곳: 제약은 "합쳐지지" 않고, 더 구체적인 패턴이 이깁니다

/staff/* 를 막아 두었으니 그 아래는 다 막혔다고 생각하기 쉽습니다. 실제로는 그 아래에 정확히 일치하는 패턴의 제약이 하나라도 있으면 /staff/* 는 고려되지 않습니다. 5b 처럼 그 제약이 일부 메서드에만 걸려 있으면, 나머지 메서드는 아무 보호도 받지 않습니다.

Tomcat 도 이 위험을 압니다. 5b 위의 SEVERE 한 줄은 웹앱이 시작될 때 찍힌 것으로, StandardContext 가 부르는 SecurityConstraint.findUncoveredHttpMethods() 가 "이 패턴은 POST 만 덮고 나머지 메서드는 덮이지 않았다"고 경고한 것입니다(StandardContext.java 4704–4710행). Context 에 denyUncoveredHttpMethods="true" 를 주면 덮이지 않은 메서드를 막는 제약을 Tomcat 이 대신 더해 줍니다(같은 메서드가 findUncoveredHttpMethods() 의 결과를 addConstraint() 로 넣습니다). 이 설정으로 실제 동작을 돌려 보지는 않았습니다.

4. 인증 — BasicAuthenticator.doAuthenticate()

BASIC 인증은 HTTP 가 정한 가장 단순한 방식입니다. 서버가 401 과 WWW-Authenticate: Basic realm="…" 헤더를 보내면, 브라우저가 아이디와 비밀번호를 물어 아이디:비밀번호 를 Base64 로 바꿔 Authorization: Basic … 헤더에 담아 다시 보냅니다. Base64 는 암호화가 아니므로 실제로는 HTTPS 위에서만 써야 합니다. BasicAuthenticator 의 구현은 50줄이 안 됩니다.

    @Override
    protected boolean doAuthenticate(Request request, HttpServletResponse response) throws IOException {

        if (checkForCachedAuthentication(request, response, true)) {
            return true;
        }

        // Validate any credentials already included with this request
        MessageBytes authorization = request.getCoyoteRequest().getMimeHeaders().getValue("authorization");

        if (authorization != null) {
            authorization.toBytes();
            ByteChunk authorizationBC = authorization.getByteChunk();
            BasicCredentials credentials;
            try {
                credentials = new BasicCredentials(authorizationBC, charset);
                String username = credentials.getUsername();
                String password = credentials.getPassword();

                Principal principal = context.getRealm().authenticate(username, password);
                if (principal != null) {
                    register(request, response, principal, HttpServletRequest.BASIC_AUTH, username, password);
                    return true;
                }
            } catch (IllegalArgumentException iae) {
                if (log.isDebugEnabled()) {
                    log.debug(sm.getString("basicAuthenticator.invalidAuthorization"), iae);
                }
            }
        }

        // the request could not be authenticated, so reissue the challenge
        StringBuilder value = new StringBuilder(16);
        value.append("Basic realm=\"");
        value.append(getRealmName(context));
        value.append('\"');
        if (charsetString != null && !charsetString.isEmpty()) {
            value.append(", charset=");
            value.append(charsetString);
        }
        response.setHeader(AUTH_HEADER_NAME, value.toString());
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
        return false;

    }

java/org/apache/catalina/authenticator/BasicAuthenticator.java 83–127행

  1. 이미 인증된 사용자가 있는지 봅니다(checkForCachedAuthentication()). 2절 1단계에서 세션의 Principal 을 요청에 옮겨 적었다면 여기서 바로 통과합니다.
  2. Authorization 헤더를 꺼냅니다. 6편에서 본 MimeHeaders·MessageBytes 를 그대로 씁니다 — 헤더를 문자열로 바꾸지 않고 바이트 상태(ByteChunk)에서 Base64 를 풉니다(BasicCredentials, 147–254행).
  3. 아이디·비밀번호를 Realm 에 넘깁니다(context.getRealm().authenticate(username, password)). 비밀번호가 맞는지는 Authenticator 가 모릅니다. 6절에서 봅니다.
  4. 맞으면 register() 로 이 요청에 사용자를 적고 true.
  5. 헤더가 없거나, 형식이 틀렸거나, Realm 이 null 을 돌려주면 WWW-Authenticate 를 붙여 401 을 보냅니다. charset=UTF-8 이 붙는 것은 charset 필드의 기본값이 UTF-8 이기 때문입니다(48–49행).

BasicAuthenticator.doAuthenticate()의 길과 예제 3의 결과. checkForCachedAuthentication()이 이미 인증된 사용자를 찾으면 바로 true다. 없으면 MimeHeaders의 MessageBytes인 Authorization 헤더를 꺼내 BasicCredentials가 ByteChunk 상태에서 Base64를 푼다. 헤더가 있고 형식이 맞으면 context.getRealm().authenticate(username, password)로 Realm에 묻는다(6절). Principal이 돌아오면 register()가 request.setUserPrincipal()과 setAuthType(BASIC)을 하고, 세션이 있으면 세션 ID를 바꾸고 Principal을 세션에 적으며, 세션이 없으면 만들지 않는다. 그 뒤 true를 돌려 5절의 hasResourcePermission()으로 가고, 예제의 2번 alice:secret은 200, 4번 bob:secret은 인증은 통과했지만 역할이 없어 403이다. 헤더가 없거나 콜론이 없어 IllegalArgumentException이 나거나 Realm이 null을 돌려주면 401과 WWW-Authenticate: Basic realm="u14 lab", charset=UTF-8을 보낸다. 예제의 1번(헤더 없음), 3번(alice:wrong), 6번(콜론 없는 base64("alice"))이 이 길이다. 위 1~5를 예제 3의 번호와 맞췄습니다. 빨간 칸의 헤더와 각 번호의 결과는 아래 예제 3의 실제 출력입니다.

register() — 인증 결과를 어디에 적나

        // Cache the authentication information in our request
        request.setAuthType(authType);
        request.setUserPrincipal(principal);

        if (sendAuthInfoResponseHeaders &&
                Boolean.TRUE.equals(request.getAttribute(Globals.REQUEST_FORWARDED_ATTRIBUTE))) {
            response.setHeader("remote-user", request.getRemoteUser());
            response.setHeader("auth-type", request.getAuthType());
        }

        Session session = request.getSessionInternal(false);

        if (session != null) {
            // If the principal is null then this is a logout. No need to change
            // the session ID. See BZ 59043.
            if (getChangeSessionIdOnAuthentication() && principal != null) {
                String newSessionId = changeSessionID(request, session);
                // If the current session ID is being tracked, update it.
                if (session.getNote(Constants.SESSION_ID_NOTE) != null) {
                    session.setNote(Constants.SESSION_ID_NOTE, newSessionId);
                }
            }
        } else if (alwaysUseSession) {
            session = request.getSessionInternal(true);
        }

        // Cache the authentication information in our session, if any
        if (session != null && cache) {
            session.setAuthType(authType);
            session.setPrincipal(principal);
        }

java/org/apache/catalina/authenticator/AuthenticatorBase.java 1164–1194행

예제 3: 401, 200, 403

ex/u14-basic 에서 conf/tomcat-users.xml 을 아래 것으로 바꾸고 /staff/hello 를 요청했습니다.

<?xml version="1.0" encoding="UTF-8"?>
<tomcat-users xmlns="http://tomcat.apache.org/xml" version="1.0">
  <role rolename="staff"/>
  <user username="alice" password="secret" roles="staff"/>
  <user username="bob" password="secret"/>
</tomcat-users>
### 1. no credentials
HTTP/1.1 401 
Cache-Control: private
WWW-Authenticate: Basic realm="u14 lab", charset=UTF-8
### 2. alice:secret (role staff)
HTTP/1.1 200 
Cache-Control: private
 uri=/basic/staff/hello user=alice authType=BASIC inRole(staff)=true
### 3. alice:wrong
HTTP/1.1 401 
Cache-Control: private
WWW-Authenticate: Basic realm="u14 lab", charset=UTF-8
### 4. bob:secret (no roles)
HTTP/1.1 403 
Cache-Control: private
### 6. Authorization: Basic base64("alice") - no colon
HTTP/1.1 401 
Cache-Control: private
WWW-Authenticate: Basic realm="u14 lab", charset=UTF-8

상태 줄 끝에 이유 문구("Unauthorized" 같은 것)가 없는 것은 Tomcat 이 이유 문구를 보내지 않기 때문입니다(Http11OutputBuffer.java 372–374행). 401 과 403 응답의 본문(Tomcat 의 HTML 오류 페이지)은 출력에서 걸렀습니다.

종료 뒤 접근 로그(%u 가 사용자 이름)도 같은 이야기를 합니다. bob 은 403 을 받았지만 인증은 된 사용자로 적혔습니다.

### access log: %u (user), request, status
- "GET /basic/staff/hello HTTP/1.1" 401
alice "GET /basic/staff/hello HTTP/1.1" 200
- "GET /basic/staff/hello HTTP/1.1" 401
bob "GET /basic/staff/hello HTTP/1.1" 403
- "GET /basic/staff/notice HTTP/1.1" 200
- "GET /basic/staff/report HTTP/1.1" 200

11.0.x 와 다른 점: 비밀번호 없는 BASIC 은 거절합니다

6번 요청은 Authorization: Basic YWxpY2U=(alice 만, 콜론 없음)입니다. 기준 커밋의 BasicCredentials.parseCredentials() 는 콜론이 없으면 예외를 던지고, doAuthenticate() 는 그것을 잡아 401 로 끝냅니다.

            // Null password is not allowed according to RFC 7617
            if (colon < 0) {
                throw new IllegalArgumentException(sm.getString("basicAuthenticator.noColon"));
            } else if (colon == 0) {
                throw new IllegalArgumentException(sm.getString("basicAuthenticator.emptyUsername"));
            } else {

java/org/apache/catalina/authenticator/BasicAuthenticator.java 244–249행

11.0.x 브랜치의 같은 자리에는 "Tomcat allows a null password" 라는 주석과 함께, 콜론이 없으면 전체를 아이디로 보고 비밀번호를 null 로 두는 코드가 있습니다. 기준 커밋의 변경 기록(webapps/docs/changelog.xml 의 12.0.0-M1 절)에도 "RFC 7617 에 맞게 비밀번호 없는 BASIC 인증을 거절한다"고 적혀 있습니다. 11.0.x 에서는 RealmBase.authenticate() 가 비밀번호 null 을 받으면 곧바로 null 을 돌려주므로(6절) 결과는 어차피 401 일 것으로 보이지만, 11.0.x 로 돌려 보지는 않았습니다.

5. 권한 — hasResourcePermission()

인증을 통과하면(또는 인증이 필요 없으면) RealmBase.hasResourcePermission() 이 역할을 판정합니다. 가운데 반복문이 핵심입니다.

            if (constraint.getAuthenticatedUsers() && principal != null) {
                if (log.isTraceEnabled()) {
                    log.trace("Passing all authenticated users");
                }
                status = true;
            } else if (roles.length == 0 && !constraint.getAllRoles() && !constraint.getAuthenticatedUsers()) {
                if (constraint.getAuthConstraint()) {
                    if (log.isTraceEnabled()) {
                        log.trace("No roles");
                    }
                    status = false; // No listed roles means no access at all
                    denyfromall = true;
                    break;
                }

                if (log.isTraceEnabled()) {
                    log.trace("Passing all access");
                }
                status = true;
            } else if (principal == null) {
                if (log.isTraceEnabled()) {
                    log.trace("  No user authenticated, cannot grant access");
                }
            } else {
                for (String role : roles) {
                    if (hasRole(request.getWrapper(), principal, role)) {
                        status = true;
                        if (log.isTraceEnabled()) {
                            log.trace("Role found:  " + role);
                        }
                    } else if (log.isTraceEnabled()) {
                        log.trace("No role found:  " + role);
                    }
                }
            }
        }

java/org/apache/catalina/realm/RealmBase.java 815–850행

반복문이 끝나고 status 가 false 면 response.sendError(HttpServletResponse.SC_FORBIDDEN, …), 곧 403 입니다(884–888행). 제약이 여럿이면 하나만 통과해도 status 가 true 가 됩니다. 다만 "아무도 못 들어온다"는 제약을 만나면 denyfromall 로 그 자리에서 끝냅니다.

hasResourcePermission()이 제약마다 내리는 판정. auth-constraint에 **(getAuthenticatedUsers)가 있고 로그인했으면 통과다. 아니면, 역할이 0개이고 *도 **도 아닐 때 빈 auth-constraint 요소면 아무도 못 들어오는 denyfromall로 바로 끝나고, auth-constraint 자체가 없으면 통과다. 아니면, 로그인한 사용자가 없을 때는 이 제약으로는 통과하지 못한다. 그 밖에는 적힌 역할마다 hasRole()을 불러 role mapping을 풀고 GenericPrincipal.hasRole()로 정렬해 둔 역할 배열을 이진 검색하며, 역할이 하나라도 있으면 통과다. 제약 가운데 하나만 통과해도 status가 true가 되고, 끝까지 false면 sendError(SC_FORBIDDEN)로 403을 보낸다. 위 반복문의 if–else 사슬을 위에서 아래로 놓았습니다. 아래 표의 다섯 줄이 이 네 갈래 가운데 어디로 가는지 맞춰 보면 됩니다.

web.xml 의 <auth-constraint> 를 어떻게 적느냐에 따라 이렇게 갈립니다. * 와 ** 는 web.xml 을 읽을 때 SecurityConstraint.addAuthRole() 이 역할 이름 대신 깃발로 바꿔 둡니다(SecurityConstraint.java 248–268행).

web.xml 의 auth-constraint 소스에서 결과
없음 getAuthConstraint() 가 false 누구나 통과 (인증도 요구하지 않음)
<auth-constraint/> (역할 없이 빈 요소) getAuthConstraint() 는 true, 역할 0개 아무도 못 들어옴 (denyfromall, 403)
<role-name>staff</role-name> findAuthRoles() 에 staff 인증 후 hasRole() 로 판정
<role-name>*</role-name> getAllRoles() web.xml 의 <security-role> 에 적은 역할 가운데 하나 (allRolesMode 로 바꿀 수 있음)
<role-name>**</role-name> getAuthenticatedUsers() 인증된 사용자면 누구나

빈 <auth-constraint/> 가 "역할 0개짜리 제약"이 되는 것은 web.xml 을 읽는 규칙이 요소를 만나면 무조건 setAuthConstraint(true) 를 부르고, 역할은 <role-name> 마다 따로 더하기 때문입니다(WebRuleSet.java 274–275행). 이 경우 2절의 hasAuthConstraint 는 false 라서 인증 없이 바로 403 이 됩니다(이 표의 두 번째 줄은 소스로만 확인했습니다).

역할 하나를 확인하는 hasRole() 은 Context 에 등록된 역할 이름 바꾸기(role mapping — PropertiesRoleMappingListener 가 properties 파일에서 읽어 넣는 것)를 먼저 풀고(901–910행), GenericPrincipal.hasRole() 에 묻습니다. 이 메서드는 생성자에서 정렬해 둔 역할 배열을 이진 검색합니다(GenericPrincipal.java 202–210행). 서블릿의 request.isUserInRole() 은 web.xml 의 <security-role-ref>(서블릿 코드 속 역할 이름과 실제 역할 이름을 잇는 별명)를 먼저 푼 뒤 결국 realm.hasRole() 을 부릅니다(Request.java 2229–2270행).

헷갈리기 쉬운 곳: 인증된 사용자의 거부는 403 — httpd 는 401

예제 3의 bob 은 비밀번호가 맞았는데 역할이 없어 403 을 받았습니다. 같은 상황에서 httpd 는 기본으로 401 을 보내 브라우저가 로그인 창을 다시 띄우게 합니다(AuthzSendForbiddenOnFailure 를 켜야 403, httpd 13편). Tomcat 은 "누구인지는 확인됐고, 그 사람은 안 된다"를 403 으로 말합니다. hasResourcePermission() 이 실패하면 언제나 SC_FORBIDDEN 을 보내고, httpd 의 AuthzSendForbiddenOnFailure 같은 스위치는 이 메서드에 없습니다.

6. Realm — 사용자 정보는 어디서 오나

이제 context.getRealm().authenticate(username, password) 의 안쪽입니다. 기본 server.xml 에는 Engine 아래에 Realm 이 두 겹으로 걸려 있습니다.

      <!-- Use the LockOutRealm to prevent attempts to guess user passwords
           via a brute-force attack -->
      <Realm className="org.apache.catalina.realm.LockOutRealm">
        <!-- This Realm uses the UserDatabase configured in the global JNDI
             resources under the key "UserDatabase".  Any edits
             that are performed against this UserDatabase are immediately
             available for use by the Realm.  -->
        <Realm className="org.apache.catalina.realm.UserDatabaseRealm"
               resourceName="UserDatabase"/>
      </Realm>

conf/server.xml 124–133행

resourceName="UserDatabase" 는 같은 파일 위쪽의 <GlobalNamingResources> 에 정의된 자원 이름이고, 그 자원이 pathname="conf/tomcat-users.xml" 을 읽는 MemoryUserDatabase 입니다(39–48행).

Realm의 구조. Authenticator 밸브가 context.getRealm을 부르면 ContainerBase.getRealm이 내게 없으면 부모에게 물어 server.xml의 Engine에 걸린 LockOutRealm에 닿는다. LockOutRealm은 실패를 세어 사용자를 잠그고, 안에 든 Realm에 차례로 묻는다. 안에 든 UserDatabaseRealm은 RealmBase를 상속하며, getPassword와 getPrincipal로 전역 JNDI 자원인 UserDatabase에서 사용자를 찾는다. UserDatabase는 MemoryUserDatabase가 conf/tomcat-users.xml을 읽은 것이고 파일이 바뀌면 다시 읽는다. 비밀번호 비교는 CredentialHandler의 matches가 하고, 기본은 algorithm이 없는 MessageDigestCredentialHandler로 평문 비교다. 성공하면 이름과 역할 목록을 가진 GenericPrincipal을 돌려준다. 노란 상자가 사용자 정보가 실제로 있는 곳입니다. 파란 상자들은 그것을 감싸고 고르는 층입니다.

RealmBase.authenticate() — 세 단계짜리 틀

UserDatabaseRealm 은 authenticate() 를 직접 구현하지 않습니다. 부모 RealmBase 의 틀을 그대로 씁니다.

    @Override
    public Principal authenticate(String username, String credentials) {
        // No user or no credentials
        // Can't possibly authenticate, don't bother doing anything.
        if (username == null || credentials == null) {
            if (containerLog.isTraceEnabled()) {
                containerLog.trace(sm.getString("realmBase.authenticateFailure", username));
            }
            return null;
        }

        // Look up the user's credentials
        String serverCredentials = getPassword(username);

        if (serverCredentials == null) {
            // User was not found
            // Waste a bit of time as not to reveal that the user does not exist.
            getCredentialHandler().mutate(credentials);

            if (containerLog.isTraceEnabled()) {
                containerLog.trace(sm.getString("realmBase.authenticateFailure", username));
            }
            return null;
        }

        boolean validated = getCredentialHandler().matches(credentials, serverCredentials);

        if (validated) {
            if (containerLog.isTraceEnabled()) {
                containerLog.trace(sm.getString("realmBase.authenticateSuccess", username));
            }
            return getPrincipal(username);
        } else {
            if (containerLog.isTraceEnabled()) {
                containerLog.trace(sm.getString("realmBase.authenticateFailure", username));
            }
            return null;
        }
    }

java/org/apache/catalina/realm/RealmBase.java 375–413행

  1. getPassword(username) — 저장된 비밀번호(또는 해시)를 가져옵니다. 추상 메서드라 Realm 마다 구현합니다(1209행). UserDatabaseRealm 은 UserDatabase 에서 사용자를 찾아 비밀번호를 돌려줍니다(UserDatabaseRealm.java 156–170행).
  2. getCredentialHandler().matches(입력, 저장된 값) — 비교는 Realm 이 아니라 CredentialHandler 가 합니다. 아래에서 봅니다.
  3. getPrincipal(username) — 맞으면 사용자 객체를 만듭니다(역시 추상, 1237행).

이 나눔 덕분에 새 Realm 은 "사용자 이름으로 비밀번호와 역할을 찾는 법"만 쓰면 됩니다. 사용자가 없을 때도 mutate() 로 해시를 한 번 계산하는 줄(392행)은, 응답 시간 차이로 "이 아이디는 없다"는 것이 드러나지 않게 하려는 것이라고 주석이 적어 두었습니다.

UserDatabaseRealm 과 MemoryRealm

둘 다 tomcat-users.xml 형식의 파일을 읽지만 읽는 시점이 다릅니다.

UserDatabaseRealm (기본 server.xml) MemoryRealm
사용자 정보 전역 JNDI 자원 UserDatabase 에 묻는다 시작할 때 파일을 읽어 자기 HashMap 에 담는다
파일이 바뀌면 MemoryUserDatabase.backgroundProcess() 가 수정 시간을 보고 다시 읽는다(watchSource 기본 true) 다시 시작해야 한다
역할 확인 UserDatabasePrincipal.hasRole() 이 그때그때 DB 에 묻는다 만들 때 받은 역할 목록
소스 UserDatabaseRealm.java 200–216행, MemoryUserDatabase.java 631–661행 MemoryRealm.java 165–188행, 232행부터

backgroundProcess() 는 11편에서 세션 만료를 처리하던 그 백그라운드 스레드가 부릅니다. 컨테이너의 backgroundProcess() 가 자기 Realm 의 backgroundProcess() 를 부르기 때문입니다(ContainerBase.java 942행). 파일을 고친 뒤 다시 읽히기까지 걸리는 시간은 돌려서 재지 않았습니다.

LockOutRealm — 여러 번 틀리면 잠근다

바깥의 LockOutRealm 은 CombinedRealm 을 상속합니다. CombinedRealm 은 안에 든 Realm 들에게 차례로 묻고 처음 성공한 결과를 씁니다(CombinedRealm.java 238–260행). LockOutRealm 은 그 결과를 한 번 더 거릅니다.

    private Principal filterLockedAccounts(String username, Principal authenticatedUser) {
        // Register all failed authentications
        if (authenticatedUser == null && isAvailable()) {
            registerAuthFailure(username);
        }

        if (isLocked(username)) {
            // If the user is currently locked, authentication will always fail
            log.warn(sm.getString("lockOutRealm.authLockedUser", username));
            return null;
        }

        if (authenticatedUser != null) {
            registerAuthSuccess(username);
        }

        return authenticatedUser;
    }

java/org/apache/catalina/realm/LockOutRealm.java 186–203행

기본값은 5번 실패(failureCount)에 300초(lockOutTime) 잠금입니다(56행, 62행). 예제 3에 이어, bob 으로 틀린 비밀번호를 다섯 번 보낸 뒤 맞는 비밀번호를 보냈습니다.

### 8. bob fails 5 times, then the right password
401 401 401 401 401 
HTTP/1.1 401 
Cache-Control: private
WWW-Authenticate: Basic realm="u14 lab", charset=UTF-8
WARNING [http-nio-8080-exec-5] org.apache.catalina.realm.LockOutRealm.filterLockedAccounts An attempt was made to authenticate the locked user [bob]
WARNING [http-nio-8080-exec-6] org.apache.catalina.realm.LockOutRealm.filterLockedAccounts An attempt was made to authenticate the locked user [bob]

(스레드 이름의 번호는 실행마다 다릅니다.) 맞는 비밀번호인데도 401 입니다. 예제 3에서 403 을 받던 bob 이 이번에는 인증 단계에서 막혔습니다. 경고는 두 번 찍혔습니다. 다섯 번째 실패에서 실패가 기록되자마자 잠겨 한 번, 맞는 비밀번호로 온 여섯 번째 요청에서 한 번입니다. 잠긴 동안은 응답만으로 비밀번호가 맞았는지 알 수 없게 되어 있습니다.

CredentialHandler — 비밀번호를 비교하는 쪽

Realm 에 <CredentialHandler> 를 따로 주지 않으면 시작할 때 MessageDigestCredentialHandler 를 하나 만듭니다(RealmBase.java 1092–1095행). 이것의 algorithm 기본값은 null 이고(MessageDigestCredentialHandler.java 72행), 그러면 비교는 이렇습니다.

    @Override
    public boolean matches(String inputCredentials, String storedCredentials) {
        if (inputCredentials == null || storedCredentials == null) {
            return false;
        }

        if (getAlgorithm() == null) {
            // No digests, compare directly
            return ConstantTime.equals(inputCredentials, storedCredentials, false);
        } else {

java/org/apache/catalina/realm/MessageDigestCredentialHandler.java 133–142행

곧 기본 설정의 tomcat-users.xml 은 평문 비밀번호를 그대로 비교합니다. 비교에 String.equals() 대신 ConstantTime.equals() 를 쓰는 것은, 앞 글자가 몇 개 맞았는지가 걸린 시간으로 드러나지 않게 하려는 것입니다(이름이 말해 주는 의도이고, 시간을 재 보지는 않았습니다).

algorithm 을 주면 저장된 값의 모양을 보고 비교 방법을 고릅니다. {MD5}·{SHA}·{SSHA} 로 시작하는 LDAP 형식, $ 가 든 소금$반복 횟수$해시 형식, 그 밖에는 16진수 해시입니다(143–197행). 소금$반복$해시 형식은 DigestCredentialHandlerBase.matchesSaltIterationsEncoded() 가 저장된 값에서 소금과 반복 횟수를 꺼내 입력을 같은 방법으로 해시해 비교합니다(DigestCredentialHandlerBase.java 171–224행). 소금(salt)은 같은 비밀번호라도 사용자마다 다른 해시가 나오게 섞는 무작위 바이트입니다.

예제 4: 해시한 비밀번호와 웹앱 전용 Realm

ex/u14-credential 은 웹앱의 META-INF/context.xml 에 이 웹앱만 쓰는 Realm 을 걸었습니다.

<?xml version="1.0" encoding="UTF-8"?>
<!-- 이 웹앱에만 쓰는 Realm. Engine 의 LockOutRealm 대신 이것이 쓰인다 -->
<Context>
  <Realm className="org.apache.catalina.realm.MemoryRealm" pathname="/tmp/hashed-users.xml">
    <CredentialHandler className="org.apache.catalina.realm.MessageDigestCredentialHandler"
                       algorithm="SHA-256"/>
  </Realm>
</Context>

저장할 해시는 Tomcat 이 주는 bin/digest.sh 로 만들었습니다(속은 RealmBase.main(), 1344행부터). 같은 비밀번호로 두 번 돌렸습니다.

### 1. digest.sh -a SHA-256 secret (twice)
secret:87494592f864df84ba1022684f2fe77c0ef1e749db4a0b7776bd1d3fba5b679a$1$3582612e77e2096b89873788efca15efc7ff5cc2d49513efec6a1fb0ef3e8dee
secret:4aae7e11b533ab57e91bf2f51fb4ab84ed754bfcfe812a4ed4c4cd8bf79c9a6b$1$b61ec840348dabc7d30be6c3de8a792a3389fde07983479f7c33b66a05b192ca

값은 실행마다 다릅니다. 소금 32바이트(16진수 64자)$반복 1번$SHA-256 해시 모양이고, 소금이 매번 무작위라 해시도 매번 다릅니다(기본 소금 길이 DEFAULT_SALT_LENGTH = 32, DigestCredentialHandlerBase.java 48행, 조립은 122–159행). 이렇게 만든 값 하나를 carol 의 비밀번호로 /tmp/hashed-users.xml 에 넣었습니다. alice 는 Engine 쪽 conf/tomcat-users.xml 에만 있습니다.

### 3. carol:secret
uri=/hashed/staff/hello user=carol authType=BASIC inRole(staff)=true
HTTP 200
### 4. carol:<the stored hash string itself>
HTTP 401
### 5. alice:secret (exists only in conf/tomcat-users.xml)
HTTP 401

11.0.x 와 다른 점: 소금의 순서, 사라진 JAASRealm

7. FORM 인증 — 로그인 페이지와 저장된 요청

BASIC 은 브라우저가 만든 창으로 로그인하지만, 대부분의 웹사이트는 직접 만든 로그인 페이지를 씁니다. 서블릿 표준의 FORM 인증이 그것이고, 규칙은 셋입니다. 로그인 폼은 j_security_check 로 POST 하고, 필드 이름은 j_username·j_password 입니다(Constants.java 42–52행). 세 번의 요청이 오가는데, 그 사이를 세션이 잇습니다.

FORM 인증의 흐름. 요청 1: 로그인 전에 GET /form/secure/hello?x=1이 오면 saveRequest가 메서드, URI, 헤더, 본문을 세션 노트에 저장하고, forwardToLoginPage가 세션 ID를 바꾸고 login.html로 forward해 200과 로그인 폼을 보낸다. 요청 2: POST /form/j_security_check에 j_username, j_password가 오면 realm.authenticate를 부른다. 실패하면 forwardToErrorPage가 200과 error.html을 보내고, 성공하면 register가 세션 ID를 또 바꾸고 세션에 Principal을 저장한 뒤 저장한 URI로 303을 보낸다. 요청 3: 새 쿠키로 GET /form/secure/hello?x=1이 오면 matchRequest가 맞는지 보고 restoreRequest로 원래 요청을 되살린 뒤 역할 검사를 거쳐 서블릿으로 간다. 회색 상자가 브라우저와 서버 사이를 오가는 요청과 응답입니다.

FormAuthenticator 는 doAuthenticate() 대신 doAuthenticateExtended()(153–344행)를 구현합니다. 그 가운데 "로그인 폼에서 온 요청인가"를 가르는 부분입니다.

        // Is this the action request from the login page?
        boolean loginAction = requestURI.startsWith(contextPath) && requestURI.endsWith(Constants.FORM_ACTION);
        if (loginAction && !Method.POST.equals(request.getMethod())) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST);
            return AuthenticationResult.FAILED;
        }

        LoginConfig config = context.getLoginConfig();

        // No -- Save this request and redirect to the form login page
        if (!loginAction) {
            // ...
            session = request.getSessionInternal(true);
            if (log.isTraceEnabled()) {
                log.trace("Save request in session '" + session.getIdInternal() + "'");
            }
            try {
                saveRequest(request, session);
            } catch (IOException ioe) {
                log.debug(sm.getString("authenticator.requestBodyTooBig"), ioe);
                response.sendError(HttpServletResponse.SC_FORBIDDEN, sm.getString("authenticator.requestBodyTooBig"));
                return AuthenticationResult.FAILED;
            }
            forwardToLoginPage(request, response, config);
            return AuthenticationResult.FAILED;
        }

java/org/apache/catalina/authenticator/FormAuthenticator.java 216–254행

AuthenticationResult 의 세 값 가운데 PASSED_CONSTRAINTS_NEED_REFRESH 는 이 바꿔치기 때문에 있습니다. 되살린 요청의 메서드가 지금 요청(GET)과 다르면 걸리는 제약이 달라질 수 있으므로, invoke() 는 제약을 다시 찾습니다(599–608행, restoreRequest() 의 663–667행).

예제 5: FORM 로그인 세 번의 요청

ex/u14-form 은 /secure/* 를 staff 역할로 막고 <auth-method>FORM</auth-method>, 로그인 페이지 /login.html, 에러 페이지 /error.html 을 둔 웹앱입니다. curl 의 쿠키 저장소(-b/-c)로 브라우저처럼 쿠키를 주고받았습니다.

### 1. GET /secure/hello?x=1 (not logged in)
HTTP/1.1 200 
Set-Cookie: JSESSIONID=2406F68DD9A3AE7DDC96CE916C146808; Path=/form; HttpOnly
 <form method="post" action="j_security_check">LOGIN PAGE
### 2. POST j_security_check alice:wrong
HTTP/1.1 200 
 LOGIN FAILED - try again
### 3. POST j_security_check alice:secret
HTTP/1.1 303 
Set-Cookie: JSESSIONID=CDA15AC5D78E8BDCE8A07E9DE2844BD8; Path=/form; HttpOnly
Location: /form/secure/hello?x=1
### 4. follow the redirect with the new cookie
HTTP/1.1 200 
 uri=/form/secure/hello user=alice authType=FORM inRole(staff)=true
### 5. next request, same cookie
HTTP/1.1 200 
 uri=/form/secure/other user=alice authType=FORM inRole(staff)=true

세션 ID 는 실행마다 다릅니다.

  1. 보호된 URL 에 200 과 로그인 폼이 왔습니다. 401 이 아닙니다. 세션 쿠키가 처음 생겼습니다.
  2. 틀린 비밀번호에 에러 페이지가 200 으로 왔습니다. 상태 코드만 보는 모니터링으로는 로그인 실패를 알 수 없다는 뜻입니다.
  3. 맞는 비밀번호에 303 과 저장해 둔 원래 URL(?x=1 쿼리까지)이 왔습니다. 쿠키의 세션 ID 가 바뀌었습니다 — 4절에서 본 register() 의 세션 고정 방지입니다.
  4. 새 쿠키로 원래 URL 에 가니 alice 로 서블릿에 닿았습니다(restoreRequest()).
  5. 다른 URL 도 비밀번호 없이 통과합니다. 2절 1단계가 세션의 Principal 을 요청에 옮겨 적었기 때문입니다.

헷갈리기 쉬운 곳: 세션 ID 는 사실 두 번 바뀝니다

forwardToLoginPage() 도 로그인 페이지를 보여 주기 직전에 세션 ID 를 바꿉니다(431–441행). 요청 1에서 세션이 막 만들어지고 곧바로 ID 가 바뀌는데, 출력에는 Set-Cookie 가 하나뿐입니다. Response.addSessionCookieInternal() 이 같은 이름의 Set-Cookie 헤더가 이미 있으면 새로 더하지 않고 값을 덮어쓰기 때문입니다(Response.java 917–945행). 바뀐 ID 는 세션 노트(SESSION_ID_NOTE)에도 적어 두었다가, 요청 2에서 "로그인 페이지를 받은 그 세션에서 온 POST 인가"를 확인하는 데 씁니다(281–291행).

로그인 전의 옛 쿠키로 다시 오면 어떻게 될까요? 이어서 돌린 결과입니다.

### 6. GET j_security_check, logged-in cookie
HTTP/1.1 404 
### 7. GET j_security_check, no cookie
HTTP 400
### 8. old cookie from step 1 after login
<form method="post" action="j_security_check">LOGIN PAGE
HTTP 200

더 깊이 들어가면

정리

개념 한 줄 요약
ContextConfig.authenticatorConfig() <auth-method> 이름으로 Authenticators.properties 에서 밸브 클래스를 찾아 Context 파이프라인에 넣는다. login-config 가 없어도 NONE 으로 넣는다
Authenticator 밸브 Context 파이프라인에서 StandardContextValve 앞. 모든 필터보다 먼저 검사한다
AuthenticatorBase.invoke() 세션의 Principal 옮기기 → 제약 찾기 → 캐시 끄기·전송 보장 → (역할 제약이면) 인증 → 역할 판정 → 다음 밸브
findSecurityConstraints() 정확히 일치 → 가장 긴 접두사 → 확장자 → /. 앞 단계에서 맞으면 뒤는 보지 않는다. 제약은 합쳐지지 않는다
BasicAuthenticator Authorization 헤더를 풀어 Realm 에 묻고, 실패하면 WWW-Authenticate + 401. 12 에서는 비밀번호 없는 헤더를 거절
register() 요청(과 세션이 있으면 세션)에 Principal 을 적고, 세션 ID 를 바꾼다
hasResourcePermission() 역할 판정. 실패하면 403. 빈 <auth-constraint/> 는 모두 거부, ** 는 인증된 누구나
Realm getRealm() 은 내게 없으면 부모에게. 기본은 Engine 의 LockOutRealm ⊃ UserDatabaseRealm → tomcat-users.xml
RealmBase.authenticate() getPassword() → CredentialHandler.matches() → getPrincipal() 의 틀
CredentialHandler 기본은 평문 비교. algorithm 을 주면 소금$반복$해시 등을 비교. digest.sh 로 만든다
FormAuthenticator 원래 요청을 세션에 저장 → 로그인 페이지 forward → j_security_check → 303 → 저장한 요청을 되살린다

다음에 볼 것

이번 글까지가 HTTP/1.1 요청 하나가 서블릿에 닿기까지 지나는 길의 마지막 관문이었습니다. 다음 글에서는 같은 연결 위에서 아예 다른 프로토콜로 갈아타는 이야기를 합니다. Upgrade 헤더 하나로 HTTP/1.1 연결이 HTTP/2 나 WebSocket 이 되는 과정, 그리고 그 뒤에는 이 글에서 본 밸브와 필터 체인을 어떻게 지나는지(또는 지나지 않는지)를 봅니다. 15편: HTTP/2와 WebSocket — 업그레이드로 갈아타는 프로토콜에서 이어집니다.

httpd 와 비교하면: httpd 13편의 httpd 는 "자격 증명을 받는 모듈"(mod_auth_basic)과 "사용자 정보를 가진 모듈"(mod_authn_file)을 프로바이더라는 이름표로 잇는데, Tomcat 에서는 Authenticator 밸브와 Realm 이 같은 짝입니다. 다만 httpd 는 Require 줄들을 트리로 만들어 AND/OR 로 판정하고, Tomcat 은 URL 패턴으로 제약을 고른 뒤 역할 목록을 봅니다. 인증된 사용자를 거절할 때 httpd 는 기본 401, Tomcat 은 403 인 것도 다릅니다(5절).

다른 글