namalzi

← 글 목록

Apache httpd 소스 파헤치기 13: 인증과 권한 — Require 한 줄은 어떻게 판정되나

Apache httpd · 게시

httpd.conf 의 AuthType Basic, AuthUserFile, Require valid-user, <RequireAll> 이 소스에서 어떻게 움직이는지 따라갑니다. "누구인가"를 묻는 인증과 "들어와도 되는가"를 묻는 권한이 왜 다른 모듈로 나뉘었는지, 이름으로 갈아 끼우는 프로바이더, Require 트리와 GRANTED·DENIED·NEUTRAL 판정, 그리고 같은 판정을 두 번 돌리는 request.c 의 순서를 실제 소스와 예제 출력으로 확인합니다.

이 글에서 다루는 것

"Apache httpd 소스 파헤치기" 연재의 열세 번째 글입니다. 12편에서는 핸들러가 파일 하나를 응답으로 바꾸는 과정을 봤습니다. 이번에는 그보다 앞, 핸들러에 닿기 전에 "이 요청을 들여보내도 되는가"를 정하는 곳으로 돌아갑니다.

httpd.conf 에서 이런 설정을 본 적이 있을 겁니다.

<Location /secret/>
  AuthType Basic
  AuthName "u13 lab"
  AuthUserFile /tmp/htpasswd
  Require valid-user
</Location>

브라우저가 아이디와 비밀번호를 묻는 창을 띄우고, 맞게 넣으면 페이지가 보입니다. 이 다섯 줄이 소스의 어디에서 어떻게 움직이는지 따라갑니다. 답하려는 질문은 네 가지입니다.

항목 내용
대상 Apache httpd
버전 2.5.1-dev (trunk)
커밋 e4af30fe6d (2026-10-24)
주로 읽는 파일 include/mod_auth.h, server/provider.c, modules/aaa/mod_authz_core.c, modules/aaa/mod_auth_basic.c, modules/aaa/mod_authn_file.c, modules/aaa/mod_authz_user.c, modules/aaa/mod_authz_host.c, server/request.c

글에 나오는 줄 번호와 세부 동작은 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받는 방법과 예제를 돌리는 도커 환경은 1편에 있습니다.

접근 제어·인증·권한 모듈이 모인 modules/aaa/ 폴더에는 C 파일만 스물네 개가 있습니다(Digest, 폼 로그인, LDAP, DB …). 이 글은 httpd 소스를 처음 여는 분이 따라올 수 있도록 Basic 인증 + 파일 + Require 라는 가장 흔한 조합 하나로 큰 줄기만 봅니다. 빼놓은 것은 글 끝의 "더 깊이 들어가면"에 이름만 적어 두었습니다.

먼저 알아 둘 말

인증(authentication, 소스에서는 줄여서 authn)은 "당신은 누구인가"를 확인하는 일입니다. 아이디와 비밀번호를 받아 맞는지 보는 것이 인증입니다. 결과는 사용자 이름 하나이고, httpd 는 그것을 request_rec 의 user 필드(r->user)에 적어 둡니다. request_rec 는 요청 하나에 대한 정보를 모두 담는 구조체입니다(3편).

권한 부여(authorization, 줄여서 authz)는 "이 요청을 들여보내도 되는가"를 정하는 일입니다. 판단의 재료는 사용자 이름일 수도 있고(Require user alice), 클라이언트 주소일 수도 있고(Require ip 127.0.0.1), 사용자와 상관없는 것일 수도 있습니다(Require all granted). 이 글에서는 줄여서 "권한"이라고 부릅니다.

Basic 인증은 HTTP 가 정한 가장 단순한 인증 방식입니다. 서버가 401 Unauthorized 와 함께 WWW-Authenticate: Basic realm="…" 헤더를 보내면, 브라우저는 아이디와 비밀번호를 물어 아이디:비밀번호 를 Base64 로 바꾼 값을 Authorization: Basic … 헤더에 담아 다시 요청합니다. Base64 는 암호화가 아니라 단순한 글자 변환이라, 실제로 쓸 때는 TLS(HTTPS) 위에서만 써야 합니다(15편).

훅(hook)은 코어가 "이 시점에 할 일이 있는 모듈은 함수를 걸어 두라"고 마련해 둔 자리입니다. 걸린 함수를 부르는 방식에는 RUN_FIRST(누군가 DECLINED 가 아닌 값을 돌려주면 거기서 멈춤)와 RUN_ALL(모두 OK 나 DECLINED 여야 계속, 하나라도 오류면 멈춤)이 있습니다. 자세한 것은 6편에 있습니다.

요청 처리 단계는 ap_process_request_internal() 이 URL 을 파일로 바꾸고, 설정을 합치고, 접근을 검사하고, 핸들러를 고르기까지 차례로 부르는 훅들입니다. 전체 순서는 10편에서 봤고, 이 글은 그 가운데 접근 검사 부분만 확대해서 봅니다.

프로바이더(provider)는 "이름으로 찾아 쓰는 함수 표"입니다. 모듈이 시작할 때 "나는 authn 무리의 file 이다"라고 함수 포인터가 든 구조체를 등록해 두면, 다른 모듈이 설정 파일에 적힌 이름 file 로 그 구조체를 찾아 씁니다. 함수 포인터는 "함수의 주소를 담는 변수"이고, 구조체에 여러 개를 모아 두면 C 에서 인터페이스처럼 쓸 수 있습니다(2편). 2절에서 자세히 봅니다.

1. 인증과 권한은 왜 나뉘어 있나

설정 다섯 줄을 소스의 주인별로 나눠 보면 이렇습니다. 디렉티브가 어느 모듈의 것인지는 각 모듈의 command_rec 표에서 확인했습니다(디렉티브 표가 무엇인지는 7편).

설정 줄 주인 모듈 하는 일
AuthType Basic, AuthName "…" mod_authn_core "여기는 Basic 방식으로 인증한다", 인증 창에 보일 이름
(자격 증명을 받는 쪽) mod_auth_basic Authorization 헤더를 풀어 아이디·비밀번호를 꺼내고, 맞는지 프로바이더에게 묻는다
AuthUserFile /tmp/htpasswd mod_authn_file 파일에서 그 아이디의 비밀번호를 찾아 비교하는 인증 프로바이더
Require valid-user mod_authz_core (+ mod_authz_user) Require 줄을 모아 트리로 만들고 판정. valid-user 라는 판정 자체는 mod_authz_user 가 맡는 권한 프로바이더

mod_authn_core.c 380–383행, mod_auth_basic.c 202–203행, mod_authn_file.c 49–51행, mod_authz_core.c 688–690행

다섯 줄 뒤에 모듈이 다섯 있습니다(mod_auth_basic 은 설정에 이름이 보이지 않지만 AuthType Basic 을 보고 일합니다). 왜 이렇게 잘게 나눴을까요? 세 축이 서로 독립이기 때문입니다.

받는 방식이 셋, 저장소가 넷이면 둘을 묶은 모듈을 열두 개 만들어야 할까요? 그러지 않으려고 "받는 쪽"과 "확인하는 쪽" 사이에 정해진 함수 표 하나(authn_provider)를 두었습니다. Basic 모듈은 비밀번호가 파일에 있는지 DB 에 있는지 모르고, 파일 모듈은 그 비밀번호가 Basic 으로 왔는지 폼으로 왔는지 모릅니다. 설정에서 AuthBasicProvider file 처럼 이름으로 이어 줄 뿐입니다. 권한 쪽도 같습니다. mod_authz_core 는 Require 뒤의 첫 단어로 권한 프로바이더를 찾아 판정을 맡기고, 자기는 그 결과를 AND/OR 로 합치는 일만 합니다.

인증과 권한을 맡는 세 축. ① 자격 증명을 받는 쪽은 mod_auth_* 로 mod_auth_basic(AuthType Basic), mod_auth_digest, mod_auth_form, trunk 의 mod_auth_bearer 가 있다. 이들은 check_password() 와 get_realm_hash() 로 된 authn_provider 함수 표를 통해, AuthBasicProvider file 처럼 이름으로 이어진 ② 사용자 정보가 있는 곳 mod_authn_* 에 묻는다. 여기에는 mod_authn_file(AuthUserFile), DBM 파일, SQL DB 의 mod_authn_dbd, LDAP 이 있다. ③ 들여보낼 조건은 mod_authz_core 가 Require 뒤 첫 단어로 권한 프로바이더를 찾고 결과를 AND/OR 로 합친다. valid-user 와 user 는 mod_authz_user 가 맡고, group, ip, env, expr 등이 있다 받는 쪽과 저장소 사이에 함수 표 하나만 두었기 때문에 둘을 묶은 모듈을 따로 만들 필요가 없습니다.

헷갈리기 쉬운 곳: 모듈 이름의 앞머리

modules/aaa/ 의 파일 이름 앞머리가 역할을 알려 줍니다. 이 규칙은 폴더의 파일 목록과 각 파일의 register_hooks 를 보고 정리한 것입니다.

2. 프로바이더 — 이름으로 찾는 함수 표

함수 표의 모양

인증 프로바이더가 채워야 하는 함수 표는 include/mod_auth.h 에 있습니다.

typedef struct {
    /* Given a username and password, expected to return AUTH_GRANTED
     * if we can validate this user/password combination.
     *
     * Use with AUTHN_PROVIDER_VERSION / AUTHN_PROVIDER_VERSION1 providers.
     */
    authn_status (*check_password)(request_rec *r, const char *user,
                                   const char *password);

    /* Given a user and realm, expected to return AUTH_USER_FOUND if we
     * can find a md5 hash of 'user:realm:password'
     *
     * Use with AUTHN_PROVIDER_VERSION / AUTHN_PROVIDER_VERSION1 providers.
     */
    authn_status (*get_realm_hash)(request_rec *r, const char *user,
                                   const char *realm, char **rethash);

} authn_provider;

include/mod_auth.h 95–112행

authn_status (*check_password)(…) 는 "인자 셋을 받아 authn_status 를 돌려주는 함수의 주소"를 담는 필드입니다. 필드가 둘인 이유는 받는 방식마다 필요한 것이 다르기 때문입니다. Basic 은 비밀번호 원문을 받으므로 check_password() 로 "이 아이디와 비밀번호가 맞나?"를 묻고, Digest 인증은 비밀번호 원문이 오지 않으므로 get_realm_hash() 로 "이 사용자의 해시를 달라"고 합니다. 프로바이더는 할 수 있는 쪽만 채우고 나머지는 NULL 로 둡니다.

돌려주는 값은 성공·실패 두 가지가 아니라 여럿입니다.

typedef enum {
    AUTH_DENIED,
    AUTH_GRANTED,
    AUTH_USER_FOUND,
    AUTH_USER_NOT_FOUND,
    AUTH_GENERAL_ERROR,
    AUTH_HANDLED
} authn_status;

include/mod_auth.h 69–76행

AUTH_DENIED(사용자는 있는데 비밀번호가 틀림)와 AUTH_USER_NOT_FOUND(그런 사용자가 없음)가 따로 있는 것이 중요합니다. "없음"이면 다음 프로바이더에게 물어볼 수 있고, "틀림"이면 거기서 끝내야 하기 때문입니다. 3절에서 이 차이가 쓰이는 곳을 봅니다.

권한 프로바이더의 함수 표도 같은 파일에 있습니다.

typedef struct {
    /* ... (설명 주석 줄임) ... */
    authz_status (*check_authorization)(request_rec *r,
                                        const char *require_line,
                                        const void *parsed_require_line);

    /* ... (설명 주석 줄임: "This function may be NULL.") ... */
    const char *(*parse_require_line)(cmd_parms *cmd, const char *require_line,
                                      const void **parsed_require_line);
} authz_provider;

include/mod_auth.h 141–161행

parse_require_line() 은 설정을 읽을 때 한 번 불려 Require 뒤의 글자들을 미리 해석해 두고, check_authorization() 은 요청마다 불려 그 해석 결과(parsed_require_line)로 판정합니다. 예를 들어 Require ip 127.0.0.1 의 127.0.0.1 을 요청마다 문자열로 다시 해석하지 않도록, mod_authz_host 는 설정 때 이것을 서브넷 구조체 배열로 바꿔 둡니다(mod_authz_host.c 86–144행). 그 결과가 void * — "무엇이든 가리킬 수 있는 포인터" — 로 넘어오므로, 해석 결과의 모양은 프로바이더마다 달라도 됩니다.

판정 결과는 다섯 가지입니다.

typedef enum {
    AUTHZ_DENIED,
    AUTHZ_GRANTED,
    AUTHZ_NEUTRAL,
    AUTHZ_GENERAL_ERROR,
    AUTHZ_DENIED_NO_USER      /* denied because r->user == NULL */
} authz_status;

include/mod_auth.h 87–93행

허가·거부 말고 AUTHZ_NEUTRAL(중립) 과 AUTHZ_DENIED_NO_USER(아직 사용자를 몰라서 거부) 가 있다는 것을 기억해 두세요. 이 둘이 4절과 5절의 열쇠입니다.

등록하고 찾기 — server/provider.c

프로바이더를 맡아 두는 곳은 코어의 server/provider.c 입니다. 파일 전체가 200줄이 안 됩니다.

AP_DECLARE(apr_status_t) ap_register_provider(apr_pool_t *pool,
                                              const char *provider_group,
                                              const char *provider_name,
                                              const char *provider_version,
                                              const void *provider)
{
    apr_hash_t *provider_group_hash, *provider_version_hash;

    if (global_providers == NULL) {
        global_providers = apr_hash_make(pool);
        global_providers_names = apr_hash_make(pool);
        apr_pool_cleanup_register(pool, NULL, cleanup_global_providers,
                                  apr_pool_cleanup_null);
    }

    /* First, deal with storing the provider away */
    provider_group_hash = apr_hash_get(global_providers, provider_group,
                                       APR_HASH_KEY_STRING);

    if (!provider_group_hash) {
        provider_group_hash = apr_hash_make(pool);
        apr_hash_set(global_providers, provider_group, APR_HASH_KEY_STRING,
                     provider_group_hash);
    }

    provider_version_hash = apr_hash_get(provider_group_hash, provider_name,
                                         APR_HASH_KEY_STRING);

    if (!provider_version_hash) {
        provider_version_hash = apr_hash_make(pool);
        apr_hash_set(provider_group_hash, provider_name, APR_HASH_KEY_STRING,
                     provider_version_hash);
    }

    /* just set it. no biggy if it was there before. */
    apr_hash_set(provider_version_hash, provider_version, APR_HASH_KEY_STRING,
                 provider);
    /* ... */

server/provider.c 35–71행

global_providers 는 파일 맨 위의 static 전역 변수입니다(24–25행). 해시(이름으로 값을 찾는 표) 세 겹입니다. 무리(group, 예: "authn") → 이름(예: "file") → 버전(예: "0") → 구조체의 주소. 해시를 만드는 메모리는 인자로 받은 풀에서 오고, 풀이 지워질 때 cleanup_global_providers() 가 불려 전역 변수를 NULL 로 되돌립니다. 풀과 cleanup 은 4편에서 봤습니다. 버전 칸은 함수 표의 모양이 바뀌어도 옛 모듈과 새 모듈이 섞이지 않게 하려는 자리입니다. 이 커밋의 인증·권한 버전은 모두 "0" 입니다(mod_auth.h 39–46행).

찾는 쪽은 같은 세 겹을 따라 내려갈 뿐입니다.

AP_DECLARE(void *) ap_lookup_provider(const char *provider_group,
                                      const char *provider_name,
                                      const char *provider_version)
{
    apr_hash_t *provider_group_hash, *provider_name_hash;

    if (global_providers == NULL) {
        return NULL;
    }

    provider_group_hash = apr_hash_get(global_providers, provider_group,
                                       APR_HASH_KEY_STRING);

    if (provider_group_hash == NULL) {
        return NULL;
    }

    provider_name_hash = apr_hash_get(provider_group_hash, provider_name,
                                      APR_HASH_KEY_STRING);

    if (provider_name_hash == NULL) {
        return NULL;
    }

    return apr_hash_get(provider_name_hash, provider_version,
                        APR_HASH_KEY_STRING);
}

server/provider.c 99–125행

돌려주는 타입이 void * 입니다. provider.c 는 그 구조체가 authn_provider 인지 authz_provider 인지 모릅니다. 꺼내 쓰는 쪽이 무리 이름으로 타입을 약속하고 알아서 형 변환합니다. 그래서 이 장치는 인증 전용이 아닙니다. 이 커밋에서 ap_lookup_provider() 를 부르는 파일을 찾아보면 캐시(modules/cache/), 로그(mod_log_config.c), 프록시의 부하 분산 방식(mod_proxy_balancer.c) 등이 함께 나옵니다. 부하 분산 쪽은 14편에서 봅니다.

mod_authn_file 이 자기를 등록하는 곳은 이렇습니다.

static const authn_provider authn_file_provider =
{
    &check_password,
    &get_realm_hash,
};

static void opt_retr(void)
{
    authn_cache_store = APR_RETRIEVE_OPTIONAL_FN(ap_authn_cache_store);
}
static void register_hooks(apr_pool_t *p)
{
    ap_register_auth_provider(p, AUTHN_PROVIDER_GROUP, "file",
                              AUTHN_PROVIDER_VERSION,
                              &authn_file_provider, AP_AUTH_INTERNAL_PER_CONF);
    ap_hook_optional_fn_retrieve(opt_retr, NULL, NULL, APR_HOOK_MIDDLE);
}

modules/aaa/mod_authn_file.c 166–182행

함수 두 개의 주소를 담은 static const 구조체 하나를 만들고, 그 주소를 "authn" 무리의 "file" 이라는 이름으로 등록합니다. register_hooks() 는 모듈이 실릴 때 한 번 불리는 함수이므로(6편), 설정 파일을 읽기 시작할 때는 이미 등록이 끝나 있습니다. ap_register_auth_provider() 는 ap_register_provider() 를 감싼 함수로, 마지막 인자(AP_AUTH_INTERNAL_PER_CONF)이면 개수를 세어 두고, 나머지 네 인자를 그대로 넘깁니다(server/request.c 2214–2227행). 이 숫자가 무엇에 쓰이는지는 "더 깊이 들어가면"에 적었습니다.

프로바이더의 등록과 조회. mod_authn_file, mod_authz_host, mod_authz_user가 시작할 때 무리·이름·버전으로 함수 표를 global_providers 해시에 등록한다. 설정을 읽을 때 AuthBasicProvider file은 authn 무리에서 file을, Require ip는 authz 무리에서 ip를 찾아 그 주소를 설정 구조체와 Require 트리 노드에 담아 둔다. 요청마다 그 주소로 check_password나 check_authorization을 부른다. 노란 원통이 provider.c 의 해시입니다. 찾는 일은 설정을 읽을 때 한 번만 하고, 요청 때는 담아 둔 주소로 바로 부릅니다.

헷갈리기 쉬운 곳: 설정을 읽을 때 이미 찾아 둡니다

ap_lookup_provider() 는 요청마다 불리지 않습니다. AuthBasicProvider file 을 처리하는 함수가 그 자리에서 찾아 설정 구조체에 주소를 담아 둡니다(mod_auth_basic.c 93–107행). Require 도 마찬가지입니다(4절). 그래서 LoadModule authz_host_module … 을 빠뜨리고 Require ip … 를 쓰면, 요청이 올 때가 아니라 설정을 읽을 때 Unknown Authz provider: ip 로 시작이 실패합니다(mod_authz_core.c 415–419행). 같은 이유로 LoadModule 줄은 그 모듈의 프로바이더를 쓰는 설정보다 앞에 있어야 합니다.

3. 인증 — mod_auth_basic 이 프로바이더에게 묻기

mod_auth_basic 은 register_hooks() 에서 ap_hook_check_authn(authenticate_basic_user, …) 으로 인증 함수를 겁니다(mod_auth_basic.c 493–500행). ap_hook_check_authn() 은 이름이 다르지만 결국 check_user_id 훅에 거는 함수입니다(server/request.c 2265–2275행). 그리고 check_user_id 는 RUN_FIRST 훅입니다(server/request.c 85–86행). Basic·Digest·폼 모듈이 모두 이 훅에 걸려 있어도, 각자 맨 앞에서 "AuthType 이 내 방식인가"를 보고 아니면 DECLINED 로 비켜 서기 때문에 결국 하나만 일합니다(mod_auth_basic.c 302–306행).

authenticate_basic_user() 의 순서는 이렇습니다.

  1. AuthType 이 Basic 이 아니면 DECLINED. AuthName 이 없으면 500 (302–313행).
  2. Authorization 헤더를 읽습니다. 헤더가 없으면 WWW-Authenticate: Basic realm="…" 헤더를 붙이고 401 을 돌려줍니다. 브라우저가 로그인 창을 띄우는 것이 이 응답 때문입니다(get_basic_auth() 248–286행).
  3. 헤더가 있으면 Base64 를 풀어 : 앞을 아이디, 뒤를 비밀번호로 나누고, 아이디를 일단 r->user 에 적습니다. 소스 주석도 "아직 인증되지 않았지만 사용자를 적어 둔다"고 말합니다(282–283행).
  4. 프로바이더 목록을 차례로 돌며 묻습니다. 아래 코드입니다.
  5. 결과가 AUTH_GRANTED 가 아니면 401(또는 500)을 돌려줍니다. 401 이면 WWW-Authenticate 를 다시 붙입니다(393–431행).
    current_provider = conf->providers;
    do {
        const authn_provider *provider;

        /* For now, if a provider isn't set, we'll be nice and use the file
         * provider.
         */
        if (!current_provider) {
            provider = ap_lookup_provider(AUTHN_PROVIDER_GROUP,
                                          AUTHN_DEFAULT_PROVIDER,
                                          AUTHN_PROVIDER_VERSION);
            /* ... */
        }
        else {
            provider = current_provider->provider;
            apr_table_setn(r->notes, AUTHN_PROVIDER_NAME_NOTE, current_provider->provider_name);
        }

        if (digest) {
            /* ... AuthBasicUseDigestAlgorithm MD5 일 때: get_realm_hash() ... */
        }
        else {
            auth_result = provider->check_password(r, sent_user, sent_pw);
        }

        apr_table_unset(r->notes, AUTHN_PROVIDER_NAME_NOTE);

        /* Something occurred. Stop checking. */
        if (auth_result != AUTH_USER_NOT_FOUND) {
            break;
        }

        /* If we're not really configured for providers, stop now. */
        if (!conf->providers) {
            break;
        }

        current_provider = current_provider->next;
    } while (current_provider);

modules/aaa/mod_auth_basic.c 331–391행

볼 곳은 두 군데입니다.

AuthBasicProvider 를 적지 않으면 conf->providers 가 비어 있으므로 기본값 AUTHN_DEFAULT_PROVIDER, 곧 "file" 을 찾아 씁니다(mod_auth.h 46행). 이 글의 예제도 AuthBasicProvider 없이 AuthUserFile 만 적었습니다.

mod_authn_file 의 check_password() 는 요청마다 파일을 열어 한 줄씩 읽으며 아이디: 로 시작하는 줄을 찾고, 찾으면 ap_password_validate() 로 비교합니다. 줄을 못 찾으면 AUTH_USER_NOT_FOUND, 비교가 틀리면 AUTH_DENIED 입니다(mod_authn_file.c 57–110행).

authenticate_basic_user() 의 흐름. check_user_id 훅(RUN_FIRST)에서 불려 AuthType 이 Basic 이 아니면 DECLINED 로 다른 방식의 모듈에게 넘긴다. Basic 이면 Authorization 헤더가 있는지 보고, 없으면(예제 2) 401 과 WWW-Authenticate: Basic realm="u13 lab" 을 돌려준다. 있으면 Base64 를 풀어 아이디와 비밀번호로 나누고 아이디를 일단 r->user 에 적은 뒤, provider->check_password(r, user, pw) 로 프로바이더에게 묻는다(AuthBasicProvider 가 없으면 file). AUTH_USER_NOT_FOUND 면 다음 프로바이더로 가고, AUTH_GRANTED 면(예제 3 alice:secret) OK 로 권한 판정으로 넘어가 결국 200 이 되고, AUTH_DENIED 면(예제 4 alice:wrong) 401 과 WWW-Authenticate, 그리고 AH01617 Password Mismatch 로그가 남는다 위 다섯 단계와 프로바이더 반복문을 아래 예제 1의 세 요청에 맞춰 그렸습니다. 다음 프로바이더로 넘어가는 것은 "그런 사용자 없음"일 때뿐입니다.

예제 1: htpasswd 와 401 → 200

이 글의 예제는 기준 커밋(e4af30fe6d)을 그대로 빌드한 httpd 2.5.1-dev 로 도커 컨테이너(Ubuntu 24.04)에서 실행했습니다. 예제 폴더는 ex/u13-authn-authz/ 이고 run.sh 하나가 사용자 파일을 만들고, 설정을 쓰고, 서버를 띄워 curl 로 요청을 보낸 뒤 오류 로그를 보여 줍니다. 설정의 핵심은 맨 위에 본 <Location /secret/> 블록 그대로이고, 로그를 읽기 쉽도록 두 줄을 더했습니다.

ErrorLogFormat "[%-m:%l] %M"
LogLevel warn authz_core:debug

ErrorLogFormat 은 로그 한 줄을 [모듈:수준] 메시지 만 남게 줄이는 설정이고(시간·PID·클라이언트 주소가 빠집니다), LogLevel … authz_core:debug 는 mod_authz_core 만 debug 수준까지 남기게 합니다. 모듈별 로그 수준이 어떻게 구현되는지는 16편에서 봅니다.

먼저 httpd 에 딸린 htpasswd 로 사용자 파일을 만듭니다(-B 는 bcrypt 해시). 출력의 해시 값은 실행마다 다르므로 앞부분만 보였습니다.

### 1. create the user file with htpasswd
Adding password for user alice
Adding password for user bob
alice:$2y$05$VlTvS8s...
bob:$2y$05$XmPtzxsqc...

이제 /secret/ 에 세 번 요청합니다. 응답 줄과 WWW-Authenticate 헤더, 그리고 그 요청 동안 쌓인 오류 로그(log|)입니다.

### 2. /secret/ without credentials
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="u13 lab"
    log| [authz_core:debug] AH01626: authorization result of Require valid-user : denied (no authenticated user yet)
    log| [authz_core:debug] AH01626: authorization result of <RequireAny>: denied (no authenticated user yet)

### 3. /secret/ alice:secret
HTTP/1.1 200 OK
    log| [authz_core:debug] AH01626: authorization result of Require valid-user : denied (no authenticated user yet)
    log| [authz_core:debug] AH01626: authorization result of <RequireAny>: denied (no authenticated user yet)
    log| [authz_core:debug] AH01626: authorization result of Require valid-user : granted
    log| [authz_core:debug] AH01626: authorization result of <RequireAny>: granted

### 4. /secret/ alice:wrong
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="u13 lab"
    log| [authz_core:debug] AH01626: authorization result of Require valid-user : denied (no authenticated user yet)
    log| [authz_core:debug] AH01626: authorization result of <RequireAny>: denied (no authenticated user yet)
    log| [auth_basic:error] AH01617: user alice: authentication failure for "/secret/index.html": Password Mismatch

401 → 200 은 예상대로입니다. 그런데 로그에 이상한 점이 둘 있습니다.

첫째는 4절, 둘째는 5절의 이야기입니다. 4번 요청의 Password Mismatch 는 위 코드에서 check_password() 가 AUTH_DENIED 를 돌려준 경우입니다(mod_auth_basic.c 402–408행). 2번 요청에 auth_basic 의 로그가 없는 것은 헤더가 없을 때의 401 은 로그를 남기지 않기 때문입니다(259–262행).

4. 권한 — Require 줄은 트리가 됩니다

줄 하나가 노드 하나

mod_authz_core 는 Require 한 줄을 authz_section_conf 구조체 하나로 만듭니다.

struct authz_section_conf {
    const char *provider_name;
    const char *provider_args;
    const void *provider_parsed_args;
    const authz_provider *provider;
    ap_method_mask_t limited;
    authz_logic_op op;
    int negate;
    /** true if this is not a real container but produced by AuthMerging;
     *  only used for logging */
    int is_merged;
    authz_section_conf *first;
    authz_section_conf *next;
};

modules/aaa/mod_authz_core.c 68–81행

같은 구조체가 두 가지로 쓰입니다.

first 와 next 두 포인터로 자식이 몇 개든 담는 이 방식은 "첫 자식–다음 형제" 표현이라고 부르는 흔한 트리 표현입니다. limited 는 <Limit GET> 같은 메서드 제한을 비트로 담는 칸입니다.

Require 를 처리하는 함수의 중심은 이렇습니다.

    section->provider_name = ap_getword_conf(cmd->pool, &args);

    if (!strcasecmp(section->provider_name, "not")) {
        section->provider_name = ap_getword_conf(cmd->pool, &args);
        section->negate = 1;
    }

    section->provider_args = args;

    /* lookup and cache the actual provider now */
    section->provider = ap_lookup_provider(AUTHZ_PROVIDER_GROUP,
                                           section->provider_name,
                                           AUTHZ_PROVIDER_VERSION);

    /* ... 없으면 "Unknown Authz provider", parse_require_line() 이 있으면 지금 부름 ... */

    if (!conf->section) {
        conf->section = create_default_section(cmd->pool);
    }

    if (section->negate && conf->section->op == AUTHZ_LOGIC_OR) {
        return apr_psprintf(cmd->pool, "negative %s directive has no effect "
                            "in %s directive",
                            cmd->cmd->name,
                            format_authz_command(cmd->pool, conf->section));
    }

modules/aaa/mod_authz_core.c 398–453행

첫 단어가 not 이면 부정 표시를 하고 다음 단어를 프로바이더 이름으로 씁니다. 그리고 conf->section 이 비어 있으면 create_default_section() 으로 뿌리를 하나 만듭니다. 그 함수는 이렇습니다.

static authz_section_conf* create_default_section(apr_pool_t *p)
{
    authz_section_conf *section = apr_pcalloc(p, sizeof(*section));

    section->op = AUTHZ_LOGIC_OR;

    return section;
}

modules/aaa/mod_authz_core.c 382–389행

op = AUTHZ_LOGIC_OR — 바로 이것이 로그에 나온 숨은 <RequireAny> 입니다. <Location> 이나 <Directory> 안에 Require 를 그냥 여러 줄 쓰면, 그 줄들은 보이지 않는 <RequireAny> 의 자식이 됩니다. 그래서 블록 바로 아래의 Require 여러 줄은 "하나만 맞으면 통과" 입니다. 로그 메시지의 <RequireAny> 라는 글자는 판정 결과를 찍을 때 provider 가 없고 op 가 OR 인 노드에 붙이는 이름입니다(format_authz_command() 368–380행).

<RequireAll>, <RequireAny>, <RequireNone> 은 add_authz_section() 이 처리합니다. 블록 이름에 따라 op 와 negate 를 정한 새 가지 노드를 만들고, 블록 안의 줄들을 ap_walk_config() 로 처리해 그 노드 아래에 매단 뒤, 바깥 노드의 자식으로 붙입니다(mod_authz_core.c 473–573행). 단, 부정이 아니고 op 가 바깥 노드와 같으면(예: 숨은 뿌리 아래의 <RequireAny>) 새 노드는 버리고 그 자식들을 바깥 노드에 바로 잇습니다(547–550행). 예제의 /any/ 로그에 <RequireAny> 가 한 번만 찍히는 것이 이 때문입니다. 블록 이름과 노드의 관계는 이렇습니다(499–512행).

설정 op negate 뜻
<RequireAll> AND 0 모두 통과해야
<RequireAny> OR 0 하나만 통과하면
<RequireNone> OR 1 하나도 통과하면 안 됨
(<RequireNotAll>) AND 1 소스에는 있지만 AUTHZ_EXTRA_CONFIGS 가 꺼져 있어 쓸 수 없음

<RequireNotAll> 디렉티브는 #ifdef AUTHZ_EXTRA_CONFIGS 안에 있고, 파일 앞에서 #undef AUTHZ_EXTRA_CONFIGS 로 꺼 두었습니다(48행, 699–704행). #ifdef 는 컴파일 전에 코드를 넣거나 빼는 C 전처리기 지시어입니다.

예제의 /mixed/ 설정은 이런 트리가 됩니다.

<Location /mixed/>
  AuthType Basic
  AuthName "u13 lab"
  AuthUserFile /tmp/htpasswd
  <RequireAll>
    Require valid-user
    Require not user bob
  </RequireAll>
</Location>

/mixed/ 설정이 만드는 Require 트리. 뿌리는 create_default_section이 만든 숨은 RequireAny(op OR)이고, 그 아래 RequireAll(op AND) 노드가 있으며, 그 자식으로 Require valid-user와 negate가 1인 Require not user bob이 first와 next로 이어진다. 판정은 apply_authz_sections가 뿌리부터 재귀로 내려가며 자식 결과를 AND나 OR로 합친다. 노란 상자는 가지(묶음), 파란 상자와 빨간 상자는 잎(Require 한 줄)입니다. 빨간 것은 부정(not)이 붙은 잎입니다.

판정 — apply_authz_sections()

요청이 오면 이 트리를 뿌리부터 재귀로 내려가며 판정합니다.

static authz_status apply_authz_sections(request_rec *r,
                                         authz_section_conf *section,
                                         authz_logic_op parent_op)
{
    authz_status auth_result;

    /* ... 이 메서드에 해당하지 않는 노드면 건너뜀 (<Limit>) ... */

    if (section->provider) {
        apr_table_setn(r->notes, AUTHZ_PROVIDER_NAME_NOTE,
                       section->provider_name);

        auth_result =
            section->provider->check_authorization(r, section->provider_args,
                                                   section->provider_parsed_args);

        apr_table_unset(r->notes, AUTHZ_PROVIDER_NAME_NOTE);
    }
    else {
        authz_section_conf *child = section->first;

        auth_result = AUTHZ_NEUTRAL;

        while (child) {
            authz_status child_result;

            child_result = apply_authz_sections(r, child, section->op);

            if (child_result == AUTHZ_GENERAL_ERROR) {
                return AUTHZ_GENERAL_ERROR;
            }

            if (child_result != AUTHZ_NEUTRAL) {
                /* ... */
                if (section->op == AUTHZ_LOGIC_AND) {
                    if (child_result == AUTHZ_DENIED) {
                        auth_result = child_result;
                        break;
                    }
                    if ((child_result == AUTHZ_DENIED_NO_USER
                         && auth_result != AUTHZ_DENIED)
| (auth_result == AUTHZ_NEUTRAL)) {
                        auth_result = child_result;
                    }
                }
                else {
                    /* AUTHZ_LOGIC_OR */
                    if (child_result == AUTHZ_GRANTED) {
                        auth_result = child_result;
                        break;
                    }
                    if ((child_result == AUTHZ_DENIED_NO_USER
                         && auth_result == AUTHZ_DENIED)
| (auth_result == AUTHZ_NEUTRAL)) {
                        auth_result = child_result;
                    }
                }
            }

            child = child->next;
        }
    }

    if (section->negate) {
        if (auth_result == AUTHZ_GRANTED) {
            auth_result = AUTHZ_DENIED;
        }
        else if (auth_result == AUTHZ_DENIED ||
                 auth_result == AUTHZ_DENIED_NO_USER) {
            /* For negated directives, if the original result was denied
             * then the new result is neutral since we can not grant
             * access simply because authorization was not rejected.
             */
            auth_result = AUTHZ_NEUTRAL;
        }
    }

    /* ... 로그 AH01626 "authorization result of …" ... */

    return auth_result;
}

modules/aaa/mod_authz_core.c 722–821행

줄인 자리 가운데 맨 끝의 AH01626 로그가, 예제에서 노드마다 한 줄씩 찍히는 authorization result of … 입니다(815–818행). 자식을 다 판정한 뒤에 찍으므로 로그에는 잎이 먼저, 뿌리가 나중에 나옵니다.

길지만 규칙은 표 하나로 정리됩니다.

상황 결과
잎 프로바이더의 check_authorization() 이 돌려준 값
AND 가지 자식 하나라도 DENIED 면 즉시 DENIED. 아니면 DENIED_NO_USER 가 하나라도 있으면 그것, 없으면 GRANTED. 모두 NEUTRAL 이면 NEUTRAL
OR 가지 자식 하나라도 GRANTED 면 즉시 GRANTED. 아니면 DENIED_NO_USER 가 DENIED 보다 우선
자식이 NEUTRAL 셈에 넣지 않음(없는 것처럼)
negate 가 붙은 노드 GRANTED → DENIED, DENIED/DENIED_NO_USER → NEUTRAL

마지막 줄이 이 함수에서 가장 중요한 결정입니다. Require not user bob 은 "bob 이면 거부"이지 "bob 이 아니면 허가"가 아닙니다. 주석 그대로 "거부당하지 않았다는 것만으로 허가할 수는 없기" 때문에, 부정한 결과는 기껏해야 중립입니다. 그래서 부정만으로는 아무도 들여보낼 수 없고, 반드시 긍정하는 줄과 <RequireAll> 로 묶어 써야 합니다. 이 규칙을 설정 단계에서 미리 검사하는 코드도 있습니다(예제 3).

그리고 DENIED_NO_USER 는 "사용자를 알면 결과가 달라질 수 있다"는 뜻으로 끝까지 살려 둡니다. AND 에서는 다른 자식이 확실히 DENIED 가 아닌 한, OR 에서는 GRANTED 가 없는 한 DENIED_NO_USER 가 위로 올라갑니다. 사용자 정보를 쓰는 프로바이더만 이 값을 돌려줍니다. valid-user 는 이렇게 짧습니다.

static authz_status validuser_check_authorization(request_rec *r,
                                                  const char *require_line,
                                                  const void *parsed_require_line)
{
    if (!r->user) {
        return AUTHZ_DENIED_NO_USER;
    }

    return AUTHZ_GRANTED;
}

modules/aaa/mod_authz_user.c 85–94행

Require user 도 r->user 가 없으면 AUTHZ_DENIED_NO_USER 를 돌려주고, 있으면 적힌 이름들과 하나씩 비교합니다(58–82행). 반면 Require ip 는 사용자와 상관없으니 GRANTED 나 DENIED 만 돌려줍니다(mod_authz_host.c 146–161행).

권한 프로바이더 목록

이 글에서 본 세 모듈이 이 커밋에서 등록하는 권한 프로바이더입니다(각 register_hooks() 에서 확인).

Require 다음 단어 등록하는 모듈 판정
all granted / all denied mod_authz_core 설정 때 granted 면 표시를 남기고, 요청 때 그 표시로 허가·거부 (982–1002행)
env 변수… mod_authz_core 환경 변수 중 하나라도 있으면 허가
method GET … mod_authz_core 요청 메서드가 목록에 있으면 허가
expr 식 mod_authz_core 식이 참이면 허가. 식에 REMOTE_USER 가 들어 있으면 거짓일 때 DENIED_NO_USER (1106–1129행)
user 이름…, valid-user mod_authz_user 위에서 본 대로
ip, host, forward-dns, local mod_authz_host 클라이언트 주소·호스트 이름

mod_authz_core.c 1149–1160행, mod_authz_user.c 128–134행, mod_authz_host.c 386–398행

Require group 은 mod_authz_groupfile 이, Require ldap-user 같은 것은 mod_authnz_ldap 이 같은 방식으로 등록합니다. 새 조건이 필요하면 authz_provider 하나를 채워 등록하는 모듈을 쓰면 되고, mod_authz_core 는 고칠 필요가 없습니다.

헷갈리기 쉬운 곳: 안쪽 블록의 Require 는 바깥 것을 "덮어씁니다"

예제 설정에는 <Directory /tmp/www> 에 Require all granted 가 있고, 그 안의 <Location /local/> 에 Require ip 127.0.0.1 이 있습니다. 둘이 AND 로 합쳐질까요? 예제 2의 로그를 보면 /local/ 요청에서 판정된 것은 Require ip 하나뿐입니다. 바깥 블록의 Require all granted 는 사라졌습니다.

설정 병합 함수가 그렇게 짜여 있습니다. 안쪽 블록에 Require 가 하나라도 있고 AuthMerging 을 따로 적지 않았으면(AUTHZ_LOGIC_UNSET), 안쪽 설정을 그대로 씁니다. 안쪽에 Require 가 하나도 없을 때만 바깥 설정을 물려받습니다(merge_authz_core_dir_config() 115–169행). AuthMerging And 나 AuthMerging Or 를 적으면 바깥 트리와 안쪽 트리를 자식 둘로 가진 새 가지를 만들어 합칩니다(131–160행). 이 가지는 로그에 AuthMerging All/AuthMerging Any 로 찍히도록 is_merged 표시가 붙습니다. 디렉터리 설정 병합 자체는 7편에서 봤습니다.

5. 요청 처리 순서 — 같은 판정을 두 번 합니다

mod_authz_core 는 훅 두 곳에 같은 판정을 겁니다

static void register_hooks(apr_pool_t *p)
{
    APR_REGISTER_OPTIONAL_FN(authz_some_auth_required);

    ap_hook_pre_config(authz_core_pre_config, NULL, NULL, APR_HOOK_MIDDLE);
    ap_hook_check_config(authz_core_check_config, NULL, NULL, APR_HOOK_MIDDLE);
    ap_hook_check_authz(authorize_user, NULL, NULL, APR_HOOK_LAST,
                        AP_AUTH_INTERNAL_PER_CONF);
    ap_hook_check_access_ex(authorize_userless, NULL, NULL, APR_HOOK_LAST,
                            AP_AUTH_INTERNAL_PER_CONF);
    /* ... 프로바이더 등록 ... */
}

modules/aaa/mod_authz_core.c 1138–1161행

authorize_userless() 는 access_checker_ex 훅에, authorize_user() 는 auth_checker 훅에 걸립니다. 그런데 두 함수의 본문은 한 줄씩이고, 같은 함수를 authorize_user_core(r, 0), authorize_user_core(r, 1) 로 인자만 바꿔 부릅니다(914–922행). 두 번째 인자의 이름은 after_authn — "인증이 끝난 뒤인가"입니다. 같은 트리를 인증 전에 한 번, 인증 뒤에 한 번 판정합니다. 결과를 HTTP 응답으로 바꾸는 authorize_user_core() 의 중심은 이렇습니다.

    auth_result = apply_authz_sections(r, conf->section, AUTHZ_LOGIC_AND);

    if (auth_result == AUTHZ_GRANTED) {
        return OK;
    }
    else if (auth_result == AUTHZ_DENIED_NO_USER) {
        if (after_authn) {
            /* ... 로그 AH01629 ... */
            return HTTP_UNAUTHORIZED;
        }
        else {
            /*
             * We need a user before we can decide what to do.
             * Get out of the way and proceed with authentication.
             */
            return DECLINED;
        }
    }
    else if (auth_result == AUTHZ_DENIED || auth_result == AUTHZ_NEUTRAL) {
        if (!after_authn || ap_auth_type(r) == NULL) {
            /* ... 로그 AH01630 "client denied by server configuration" ... */
            return HTTP_FORBIDDEN;
        }
        else {
            /* ... 로그 AH01631, AuthzSendForbiddenOnFailure 에 따라 403 또는 401 ... */
        }
    }

modules/aaa/mod_authz_core.c 849–905행

1차 판정(인증 전)의 결과는 셋 중 하나입니다(프로바이더 오류인 AUTHZ_GENERAL_ERROR 는 500 으로 끝납니다, 906–911행).

DENIED_NO_USER 라는 값이 따로 있는 이유가 여기서 드러납니다. 이 값이 없으면 1차 판정은 "인증이 필요한 거부"와 "인증해도 소용없는 거부"를 구별하지 못합니다.

request.c 는 이 결과로 다음 단계를 고릅니다

호출하는 쪽은 10편에서 본 ap_process_request_internal() 입니다. URL 을 파일로 바꾸고 디렉터리 설정을 합친 뒤, type_checker 와 fixups 직전에 이 부분이 있습니다. 쓰이는 훅의 실행 방식은 server/request.c 앞쪽의 구현 매크로에서 확인할 수 있습니다. access_checker 는 RUN_ALL, access_checker_ex·check_user_id·auth_checker 는 RUN_FIRST 입니다(85–96행).

        switch (ap_satisfies(r)) {
        case SATISFY_ALL:
        case SATISFY_NOSPEC:
            if ((access_status = ap_run_token_checker(r)) != OK &&
            		access_status != DECLINED) {
                return decl_die(access_status,
                                "check token (with Satisfy All)", r);
            }

            if ((access_status = ap_run_access_checker(r)) != OK) {
                return decl_die(access_status,
                                "check access (with Satisfy All)", r);
            }

            access_status = ap_run_access_checker_ex(r);
            if (access_status == DECLINED
| (access_status == OK && ap_run_force_authn(r) == OK)) {
                if ((access_status = ap_run_check_user_id(r)) != OK) {
                    return decl_die(access_status, "check user", r);
                }
                if (r->user == NULL) {
                    /* don't let buggy authn module crash us in authz */
                    /* ... 로그 AH00027, 500 ... */
                }
                if ((access_status = ap_run_auth_checker(r)) != OK) {
                    return decl_die(access_status, "check authorization", r);
                }
            }
            else if (access_status == OK) {
                ap_log_rerror(APLOG_MARK, APLOG_TRACE3, 0, r,
                              "request authorized without authentication by "
                              "access_checker_ex hook: %s", r->uri);
            }
            else {
                return decl_die(access_status, "check access", r);
            }
            break;
        case SATISFY_ANY:
            /* ... */

server/request.c 336–379행

순서대로 읽으면 이렇습니다.

  1. token_checker — trunk 에 새로 생긴 토큰 인증(mod_auth_bearer) 자리입니다. OK·DECLINED 가 아닌 값일 때만 멈춥니다. 2.4.x 브랜치의 request.c 에는 이 호출이 없습니다.
  2. access_checker (RUN_ALL) — 사용자와 상관없는 접근 검사입니다. 이 커밋에서는 mod_access_compat(옛 방식의 Allow from/Deny from/Order 디렉티브를 가진 모듈)과 mod_ssl 등이 걸어 둡니다. 하나라도 OK 가 아니면 끝입니다.
  3. access_checker_ex (RUN_FIRST) — mod_authz_core 의 1차 판정.
  4. 3이 DECLINED 면(또는 OK 인데 force_authn 훅이 OK 라고 하면 — 이 커밋의 modules/ 에는 이 훅을 거는 모듈이 없습니다) check_user_id 로 인증합니다. Basic 모듈이 401 을 돌려주면 여기서 끝납니다. 인증이 OK 인데 r->user 가 비어 있으면 인증 모듈의 버그로 보고 500 입니다.
  5. 이어서 auth_checker (RUN_FIRST) — mod_authz_core 의 2차 판정. 이제 r->user 가 있습니다.
  6. 3이 OK 면 인증 없이 통과합니다. trace3 로그 "request authorized without authentication…"이 이때 찍힙니다.
  7. 3이 그 밖의 값(403 등)이면 그것으로 끝입니다.

request.c의 접근 검사 순서. access_checker가 OK가 아니면 끝난다. OK면 access_checker_ex에서 mod_authz_core가 사용자 없이 1차 판정을 한다. OK면 인증 없이 다음 단계로, HTTP 오류면 403이나 500으로 끝나고, DECLINED면 check_user_id에서 mod_auth_basic이 프로바이더에게 묻는다. 여기서 401이나 500이면 끝나고, OK로 r->user가 채워지면 auth_checker에서 mod_authz_core가 2차 판정을 해 OK면 다음 단계로, 거부면 401이나 403으로 끝난다. Satisfy All(기본)일 때의 길입니다. 파란 상자가 훅, 빨간 상자가 요청이 끝나는 곳입니다. 토큰 검사(token_checker)는 그림에서 뺐습니다.

decl_die() 는 훅이 DECLINED 를 돌려준 경우(아무 모듈도 맡지 않음)를 설정 오류로 보고 500 으로 바꾸고, 그 밖의 값은 trace3 로그를 남기고 그대로 돌려줍니다(115–128행). AuthType 을 적지 않은 곳에서 4번에 이르면 어떻게 될까요? mod_authn_core 가 check_user_id 훅의 맨 끝(APR_HOOK_LAST)에 authenticate_no_user() 를 걸어 두었다가, AuthType 이 없으면 OK 를 돌려줍니다(mod_authn_core.c 390–415행). 그러면 r->user 가 비어 있으므로 위 4번의 500 이 납니다. Require valid-user 만 쓰고 AuthType 을 빠뜨린 설정이 500 을 내는 길이 이것입니다(예제로 돌려 보지는 않았습니다).

Satisfy 는 어디서 오나

switch (ap_satisfies(r)) 의 값은 Satisfy All/Satisfy Any 디렉티브에서 옵니다. 그런데 이 디렉티브는 코어가 아니라 mod_access_compat 의 것입니다. 코어의 ap_satisfies() 는 그 모듈이 선택 함수(optional function — 모듈이 있으면 부르고 없으면 건너뛰는 함수)를 내놓았으면 그것을 부르고, 없으면 SATISFY_NOSPEC 을 돌려줍니다(server/core.c 842–850행). 그래서 mod_access_compat 을 싣지 않은 설정(이 글의 예제)에서는 늘 SATISFY_ALL 과 같은 길로 갑니다.

Satisfy Any 일 때는 2번 access_checker 가 OK 면 인증과 권한을 모두 건너뜁니다(388–394행). 옛 설정에서 "이 IP 에서 오거나, 아니면 비밀번호를 대거나"를 쓰던 방법입니다. Require 로는 <RequireAny> 에 Require ip 와 Require valid-user 를 같이 넣으면 같은 일을 합니다. 1차 판정에서 IP 가 맞으면 GRANTED 로 인증을 건너뛰고, 아니면 DENIED_NO_USER 로 인증을 요구하니까요. 예제 2가 바로 그것입니다.

ap_some_authn_required() 도 같은 파일에 있습니다(130–164행). 위 순서의 2·3번을 미리 돌려 보고 "access_checker_ex 가 DECLINED 를 돌려주는가", 곧 이 요청에 인증이 필요한가를 알려 주는 공개 함수입니다. 돌려 본 뒤 r->user 를 원래 값으로 되돌려 놓습니다. 이 커밋의 modules/ 와 server/ 에서는 이 함수를 부르는 곳을 찾지 못했습니다. 밖의 모듈이 쓰라고 내놓은 API 로 보입니다.

예제 2: 1차에서 끝나는 요청, 2차까지 가는 요청

예제 설정의 나머지 두 블록입니다.

<Location /local/>
  Require ip 127.0.0.1
</Location>

<Location /any/>
  AuthType Basic
  AuthName "u13 lab"
  AuthUserFile /tmp/htpasswd
  <RequireAny>
    Require ip 127.0.0.1
    Require valid-user
  </RequireAny>
</Location>

컨테이너 안에서 127.0.0.1 로 보내면 클라이언트 주소가 127.0.0.1 이고, 컨테이너 자기 IP(도커 네트워크의 172.17.x.x — 실행마다 다를 수 있습니다)로 보내면 클라이언트 주소도 그 IP 가 됩니다. 이것으로 "다른 주소"를 만들었습니다.

### 5. /local/ from 127.0.0.1
HTTP/1.1 200 OK
    log| [authz_core:debug] AH01626: authorization result of Require ip 127.0.0.1: granted
    log| [authz_core:debug] AH01626: authorization result of <RequireAny>: granted

### 6. /local/ from the container IP (another address)
HTTP/1.1 403 Forbidden
    log| [authz_core:debug] AH01626: authorization result of Require ip 127.0.0.1: denied
    log| [authz_core:debug] AH01626: authorization result of <RequireAny>: denied
    log| [authz_core:error] AH01630: client denied by server configuration: /tmp/www/local/index.html

### 7. /any/ from 127.0.0.1, no credentials
HTTP/1.1 200 OK
    log| [authz_core:debug] AH01626: authorization result of Require ip 127.0.0.1: granted
    log| [authz_core:debug] AH01626: authorization result of <RequireAny>: granted

### 8. /any/ from another address, no credentials
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="u13 lab"
    log| [authz_core:debug] AH01626: authorization result of Require ip 127.0.0.1: denied
    log| [authz_core:debug] AH01626: authorization result of Require valid-user : denied (no authenticated user yet)
    log| [authz_core:debug] AH01626: authorization result of <RequireAny>: denied (no authenticated user yet)

### 9. /any/ from another address, alice
HTTP/1.1 200 OK
    log| [authz_core:debug] AH01626: authorization result of Require ip 127.0.0.1: denied
    log| [authz_core:debug] AH01626: authorization result of Require valid-user : denied (no authenticated user yet)
    log| [authz_core:debug] AH01626: authorization result of <RequireAny>: denied (no authenticated user yet)
    log| [authz_core:debug] AH01626: authorization result of Require ip 127.0.0.1: denied
    log| [authz_core:debug] AH01626: authorization result of Require valid-user : granted
    log| [authz_core:debug] AH01626: authorization result of <RequireAny>: granted

요청마다 위 순서의 어디서 끝났는지 짚어 보겠습니다.

다른 주소에서 alice로 /any/를 요청할 때의 두 번의 판정. 1차 access_checker_ex에서는 r->user가 NULL이라 Require ip는 denied, Require valid-user는 사용자 없음으로 거부되어 RequireAny가 DENIED_NO_USER가 되고 DECLINED를 돌려준다. check_user_id에서 mod_auth_basic이 Authorization 헤더를 mod_authn_file로 확인해 AUTH_GRANTED가 되고 r->user가 alice가 된다. 2차 auth_checker에서는 Require ip가 denied, Require valid-user가 granted라 RequireAny가 GRANTED가 되어 200이 나간다. 예제 9번 요청입니다. 회색 상자의 내용이 로그에 찍힌 판정 결과와 같습니다.

전체 로그 수준을 trace3 으로 올리면 request.c 가 남기는 줄도 볼 수 있습니다. 위 설정의 LogLevel 만 LogLevel trace3 으로 바꾸고 /any/ 를 127.0.0.1 에서, /secret/ 을 자격 증명 없이 요청한 뒤 관련 줄만 골라낸 결과입니다. 숫자 줄은 응답 코드이고, httpd 를 켜고 끌 때 표준 오류에 찍히는 Setting LogLevel for all modules to trace3 줄은 뺐습니다.

### 12. request.c decisions at trace3 (/any/, /secret/)
200
    log| [core:trace3] request authorized without authentication by access_checker_ex hook: /any/index.html
401
    log| [core:trace3] auth phase 'check user' gave status 401: /secret/index.html

첫 줄은 위 코드의 6번(access_checker_ex 가 OK), 둘째 줄은 decl_die() 가 check_user_id 의 401 을 그대로 돌려줄 때 남긴 것입니다. request.c 는 모듈이 아니라 코어의 일부라 [core:…] 로 찍힙니다.

헷갈리기 쉬운 곳: 인증된 사용자의 거부는 403 이 아니라 401

예제의 /mixed/ 에 alice 로 들어가면 200 입니다(10번 요청, 2차 판정에서 Require not user bob: neutral, <RequireAll>: granted). bob 으로 들어가 봅니다.

### 11. /mixed/ bob
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="u13 lab"
    log| [authz_core:debug] AH01626: authorization result of Require valid-user : denied (no authenticated user yet)
    log| [authz_core:debug] AH01626: authorization result of Require not user bob: neutral
    log| [authz_core:debug] AH01626: authorization result of <RequireAll>: denied (no authenticated user yet)
    log| [authz_core:debug] AH01626: authorization result of <RequireAny>: denied (no authenticated user yet)
    log| [authz_core:debug] AH01626: authorization result of Require valid-user : granted
    log| [authz_core:debug] AH01626: authorization result of Require not user bob: denied
    log| [authz_core:debug] AH01626: authorization result of <RequireAll>: denied
    log| [authz_core:debug] AH01626: authorization result of <RequireAny>: denied
    log| [authz_core:error] AH01631: user bob: authorization failure for "/mixed/index.html": 

4절의 규칙이 그대로 보입니다.

그런데 bob 은 비밀번호를 맞게 넣었는데도 응답이 403 이 아니라 401 이고, 브라우저는 로그인 창을 다시 띄웁니다. authorize_user_core() 의 마지막 분기에서 AuthzSendForbiddenOnFailure 를 켜지 않았으면(authz_forbidden_on_fail 이 UNSET, 곧 -1) 401 을 돌려주기 때문입니다(886–903행). 디렉티브 설명에도 "HTTP 규약에 맞는 401 대신 403 을 보낼지"라고 적혀 있습니다(713–718행). 다른 계정으로 다시 로그인할 기회를 주는 것이 기본값이라는 뜻입니다.

덤으로 로그의 Require valid-user : 에는 콜론 앞에 빈칸이 하나 있습니다. 로그 문구를 만드는 format_authz_command() 가 이름 뒤에 빈칸 하나와 인자를 붙이는데, valid-user 는 인자가 빈 문자열이기 때문입니다(372–374행). 로그를 grep 할 때 알아 두면 좋습니다.

예제 3: 부정은 설정 단계에서 검사합니다

4절에서 "부정만으로는 아무도 들여보낼 수 없다"고 했습니다. mod_authz_core 는 이런 설정을 시작할 때 막습니다. 예제는 같은 컨테이너에서 httpd -t(설정 검사만 하고 끝냄)로 돌렸습니다.

### 13. config check: a single Require not in <Location>
AH00526: Syntax error on line 10 of /tmp/bad1.conf:
negative Require directive has no effect in <RequireAny> directive

### 14. config check: <RequireAll> with only negations
AH01624: <RequireAll> directive contains only negative authorization directives
AH00014: Configuration check failed

### 15. correct: one positive + one negative in <RequireAll>
Syntax OK

더 깊이 들어가면

이 글에서 이름만 꺼냈거나 다루지 않은 것들입니다.

정리

개념 한 줄 요약
인증(authn) "누구인가". 결과는 r->user. check_user_id 훅에서 mod_auth_basic 같은 앞단이 한다
권한(authz) "들여보내도 되는가". mod_authz_core 가 Require 트리를 판정한다
프로바이더 무리·이름·버전으로 등록하고 찾는 함수 표. 설정을 읽을 때 찾아 주소를 담아 둔다 (server/provider.c)
authn_provider check_password(), get_realm_hash(). "사용자 없음"이면 다음 프로바이더로, "비밀번호 틀림"이면 멈춤
authz_provider parse_require_line()(설정 때 한 번), check_authorization()(요청마다)
Require 트리 줄 하나가 잎, <RequireAll/Any/None> 이 가지. 블록 바로 아래 줄들은 숨은 <RequireAny> 의 자식
NEUTRAL 셈에 넣지 않는 결과. 부정(not)한 거부는 허가가 아니라 중립이 된다
DENIED_NO_USER "사용자를 알면 달라질 수 있는 거부". 1차 판정에서 인증을 부르는 신호
두 번의 판정 access_checker_ex 에서 사용자 없이 한 번, 인증 뒤 auth_checker 에서 한 번. 1차에서 정해지면 인증을 건너뛴다
AuthMerging 기본은 안쪽 블록의 Require 가 바깥 것을 대신한다. And/Or 로 합칠 수 있다
Satisfy mod_access_compat 의 옛 디렉티브. 없으면 SATISFY_NOSPEC 으로 All 과 같은 길

다음에 볼 것

이번 글로 요청 하나가 들어와서 판정받고, 핸들러가 응답을 만들고(12편), 필터를 지나 나가는(11편) 주 경로를 한 바퀴 다 돌았습니다. 다음부터는 이 주 경로 위에 얹힌 큰 모듈들을 봅니다.

첫 번째는 httpd 를 앞단 서버로 쓸 때 가장 많이 만나는 mod_proxy 입니다. ProxyPass 한 줄이 어떻게 뒤쪽 서버로 가는 연결이 되는지, 연결을 어떻게 재사용하는지, 그리고 부하 분산 방식(byrequests 등)이 이번 글에서 본 프로바이더로 갈아 끼워지는 모습을 봅니다. 14편: mod_proxy — 리버스 프록시와 로드 밸런서에서 이어집니다.

Spring 과 비교하면: Spring Framework 자체에는 인증·권한 기능이 없고, 별도 프로젝트인 Spring Security 가 서블릿 필터 체인 위에서 이 일을 합니다(이 연재와 Spring 연재 모두 Spring Security 의 소스는 읽지 않았습니다). Spring Framework 안에서 "핸들러에 닿기 전에 통과 여부를 정하는 자리"에 가장 가까운 것은 Spring 23편의 HandlerInterceptor.preHandle() 입니다. 다만 그쪽은 인터셉터가 true/false 둘 중 하나를 돌려주고 앞에서부터 차례로 불리는 줄이고, httpd 는 판정을 프로바이더에 맡기고 GRANTED·DENIED·NEUTRAL·DENIED_NO_USER 를 트리로 합친다는 점이 다릅니다. 모듈이 코어의 흐름에 끼어드는 훅 자체는 6편에서, Spring 이 같은 문제를 PostProcessor 로 푸는 방식은 Spring 6편에서 볼 수 있습니다.

다른 글