mirror of
https://github.com/apache/httpd.git
synced 2026-10-23 22:49:01 +03:00
test/modules/aaa: Add a pytest suite for mod_auth_digest.
Assisted-by: Claude Sonnet 5 <noreply@anthropic.com> GitHub: PR #705 git-svn-id: https://svn.apache.org/repos/asf/httpd/httpd/trunk@1937103 13f79535-47bb-0310-9956-ffa450edef68
This commit is contained in:
87
test/modules/aaa/conftest.py
Normal file
87
test/modules/aaa/conftest.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from .env import AAATestEnv
|
||||
from pyhttpd.conf import HttpdConf
|
||||
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), '../..'))
|
||||
|
||||
|
||||
def pytest_report_header(config, start_path):
|
||||
env = AAATestEnv()
|
||||
return f"mod_auth_digest [apache: {env.get_httpd_version()}, mpm: {env.mpm_module}, {env.prefix}]"
|
||||
|
||||
|
||||
def _digest_dir(docs, path, extra_lines):
|
||||
lines = [
|
||||
f'<Directory "{docs}/digest/{path}">',
|
||||
' AuthType Digest',
|
||||
f' AuthName "{AAATestEnv.REALM}"',
|
||||
]
|
||||
lines.extend(f" {l}" for l in extra_lines)
|
||||
lines.append(' Require valid-user')
|
||||
lines.append('</Directory>')
|
||||
return lines
|
||||
|
||||
|
||||
@pytest.fixture(scope="package")
|
||||
def env(pytestconfig) -> AAATestEnv:
|
||||
level = logging.INFO
|
||||
console = logging.StreamHandler()
|
||||
console.setLevel(level)
|
||||
console.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
|
||||
logging.getLogger('').addHandler(console)
|
||||
logging.getLogger('').setLevel(level=level)
|
||||
env = AAATestEnv(pytestconfig=pytestconfig)
|
||||
env.setup_httpd()
|
||||
env.apache_access_log_clear()
|
||||
env.httpd_error_log.clear_log()
|
||||
|
||||
docs = env.server_docs_dir
|
||||
pwfile = env.digest_pwfile
|
||||
conf = HttpdConf(env)
|
||||
conf.add(_digest_dir(docs, "default", [
|
||||
'AuthDigestProvider file',
|
||||
f'AuthUserFile "{pwfile}"',
|
||||
]))
|
||||
conf.add(_digest_dir(docs, "nccheck", [
|
||||
'AuthDigestProvider file',
|
||||
f'AuthUserFile "{pwfile}"',
|
||||
'AuthDigestNcCheck On',
|
||||
]))
|
||||
conf.add(_digest_dir(docs, "shortlife", [
|
||||
'AuthDigestProvider file',
|
||||
f'AuthUserFile "{pwfile}"',
|
||||
'AuthDigestNonceLifetime 2',
|
||||
]))
|
||||
conf.add(_digest_dir(docs, "neverexpire", [
|
||||
'AuthDigestProvider file',
|
||||
f'AuthUserFile "{pwfile}"',
|
||||
'AuthDigestNonceLifetime -1',
|
||||
]))
|
||||
conf.add(_digest_dir(docs, "onetime", [
|
||||
'AuthDigestProvider file',
|
||||
f'AuthUserFile "{pwfile}"',
|
||||
'AuthDigestNonceLifetime 0',
|
||||
]))
|
||||
conf.add(_digest_dir(docs, "domain", [
|
||||
'AuthDigestProvider file',
|
||||
f'AuthUserFile "{pwfile}"',
|
||||
'AuthDigestDomain "/digest/domain/" "https://mirror.example.org/other/"',
|
||||
]))
|
||||
conf.add(_digest_dir(docs, "noprovider", [
|
||||
# AuthDigestProvider intentionally omitted: falls back to "file".
|
||||
f'AuthUserFile "{pwfile}"',
|
||||
]))
|
||||
conf.install()
|
||||
assert env.apache_restart() == 0
|
||||
return env
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="package")
|
||||
def _stop_package_scope(env):
|
||||
yield
|
||||
assert env.apache_stop() == 0
|
||||
134
test/modules/aaa/digest_client.py
Normal file
134
test/modules/aaa/digest_client.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Minimal hand-rolled RFC 2617 Digest auth client.
|
||||
|
||||
curl's own `--digest` handles the challenge/response handshake transparently,
|
||||
which is no good for testing edge cases (tampered nonces, replayed
|
||||
nonce-counts, wrong realms, bad algorithm tokens, ...). This module lets
|
||||
tests parse a WWW-Authenticate challenge, compute the expected response by
|
||||
hand, and build a (possibly deliberately broken) Authorization header.
|
||||
|
||||
mod_auth_digest here only implements qop="auth" (see modules/aaa/mod_auth_digest.c
|
||||
Open Issues: "MD5-sess and auth-int are not yet implemented"), so this client
|
||||
only implements the qop=auth request-digest/response-auth formulas from
|
||||
RFC 2617 section 3.2.2.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
_PARAM_RE = re.compile(r'(\w+)=(?:"([^"]*)"|([^\s,]+))\s*,?\s*')
|
||||
|
||||
|
||||
def _md5hex(s: str) -> str:
|
||||
return hashlib.md5(s.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def parse_params(value: str) -> Dict[str, str]:
|
||||
"""Parse a comma-separated key=value / key="value" list, as used by
|
||||
both WWW-Authenticate and Authentication-Info header values."""
|
||||
params = {}
|
||||
for m in _PARAM_RE.finditer(value):
|
||||
key = m.group(1)
|
||||
val = m.group(2) if m.group(2) is not None else m.group(3)
|
||||
params[key.lower()] = val
|
||||
return params
|
||||
|
||||
|
||||
@dataclass
|
||||
class DigestChallenge:
|
||||
realm: Optional[str]
|
||||
nonce: Optional[str]
|
||||
algorithm: Optional[str] = None
|
||||
opaque: Optional[str] = None
|
||||
domain: Optional[str] = None
|
||||
qop: Optional[str] = None
|
||||
stale: bool = False
|
||||
raw: str = ""
|
||||
|
||||
@staticmethod
|
||||
def parse(www_authenticate: str) -> 'DigestChallenge':
|
||||
assert www_authenticate.startswith("Digest "), \
|
||||
f"not a Digest challenge: {www_authenticate}"
|
||||
params = parse_params(www_authenticate[len("Digest "):])
|
||||
return DigestChallenge(
|
||||
realm=params.get('realm'),
|
||||
nonce=params.get('nonce'),
|
||||
algorithm=params.get('algorithm'),
|
||||
opaque=params.get('opaque'),
|
||||
domain=params.get('domain'),
|
||||
qop=params.get('qop'),
|
||||
stale=params.get('stale', '').lower() == 'true',
|
||||
raw=www_authenticate,
|
||||
)
|
||||
|
||||
def domain_list(self) -> List[str]:
|
||||
return self.domain.split() if self.domain else []
|
||||
|
||||
|
||||
def ha1(username: str, realm: str, password: str) -> str:
|
||||
return _md5hex(f"{username}:{realm}:{password}")
|
||||
|
||||
|
||||
def ha2(method: str, uri: str) -> str:
|
||||
return _md5hex(f"{method}:{uri}")
|
||||
|
||||
|
||||
def request_digest(ha1_hex: str, nonce: str, nc: str, cnonce: str,
|
||||
qop: str, ha2_hex: str) -> str:
|
||||
return _md5hex(f"{ha1_hex}:{nonce}:{nc}:{cnonce}:{qop}:{ha2_hex}")
|
||||
|
||||
|
||||
def rspauth_digest(ha1_hex: str, nonce: str, nc: str, cnonce: str,
|
||||
qop: str, uri: str) -> str:
|
||||
"""Authentication-Info's rspauth uses A2 = ':' + uri (no method)."""
|
||||
ha2_hex = _md5hex(f":{uri}")
|
||||
return _md5hex(f"{ha1_hex}:{nonce}:{nc}:{cnonce}:{qop}:{ha2_hex}")
|
||||
|
||||
|
||||
def build_authorization(username: str, challenge: DigestChallenge, password: str,
|
||||
method: str, uri: str, nc: str = "00000001",
|
||||
cnonce: str = "0a4f113b3c2e7a1d", qop: Optional[str] = "auth",
|
||||
realm: Optional[str] = None, nonce_val: Optional[str] = None,
|
||||
algorithm: Optional[str] = None, response: Optional[str] = None,
|
||||
opaque: Optional[str] = None, include_opaque: bool = True,
|
||||
include_qop_fields: bool = True, extra: Optional[List[str]] = None
|
||||
) -> str:
|
||||
"""Build a Digest Authorization header value.
|
||||
|
||||
By default this builds a *correct* response for the given challenge and
|
||||
credentials. Any of realm=/nonce_val=/algorithm=/response=/opaque= can be
|
||||
overridden to construct deliberately invalid headers, and qop=None with
|
||||
include_qop_fields=False builds a legacy RFC 2069-style header (no qop,
|
||||
cnonce, or nc) to prove that path is rejected.
|
||||
"""
|
||||
eff_realm = challenge.realm if realm is None else realm
|
||||
eff_nonce = challenge.nonce if nonce_val is None else nonce_val
|
||||
if response is None:
|
||||
h1 = ha1(username, eff_realm, password)
|
||||
h2 = ha2(method, uri)
|
||||
if qop:
|
||||
response = request_digest(h1, eff_nonce, nc, cnonce, qop, h2)
|
||||
else:
|
||||
# legacy RFC 2069: MD5(HA1:nonce:HA2), no qop/cnonce/nc
|
||||
response = _md5hex(f"{h1}:{eff_nonce}:{h2}")
|
||||
|
||||
parts = [
|
||||
f'username="{username}"',
|
||||
f'realm="{eff_realm}"',
|
||||
f'nonce="{eff_nonce}"',
|
||||
f'uri="{uri}"',
|
||||
f'response="{response}"',
|
||||
]
|
||||
if algorithm is not None:
|
||||
parts.append(f'algorithm={algorithm}')
|
||||
if qop and include_qop_fields:
|
||||
parts.append(f'qop={qop}')
|
||||
parts.append(f'nc={nc}')
|
||||
parts.append(f'cnonce="{cnonce}"')
|
||||
eff_opaque = challenge.opaque if (opaque is None and include_opaque) else opaque
|
||||
if eff_opaque:
|
||||
parts.append(f'opaque="{eff_opaque}"')
|
||||
if extra:
|
||||
parts.extend(extra)
|
||||
return "Digest " + ", ".join(parts)
|
||||
79
test/modules/aaa/env.py
Normal file
79
test/modules/aaa/env.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import hashlib
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Optional
|
||||
|
||||
from pyhttpd.env import HttpdTestEnv, HttpdTestSetup
|
||||
from pyhttpd.result import ExecResult
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AAATestSetup(HttpdTestSetup):
|
||||
|
||||
def __init__(self, env: 'HttpdTestEnv'):
|
||||
super().__init__(env=env)
|
||||
self.add_source_dir(os.path.dirname(inspect.getfile(AAATestSetup)))
|
||||
self.add_modules(["auth_digest", "authn_file", "authn_core",
|
||||
"authz_core", "authz_user"])
|
||||
|
||||
|
||||
class AAATestEnv(HttpdTestEnv):
|
||||
|
||||
REALM = "AAA Digest Realm"
|
||||
DIGEST_USER = "digestuser"
|
||||
DIGEST_PASSWORD = "digestpass2617"
|
||||
DIGEST_USER2 = "otheruser"
|
||||
DIGEST_PASSWORD2 = "otherpass2617"
|
||||
|
||||
def __init__(self, pytestconfig=None):
|
||||
super().__init__(pytestconfig=pytestconfig)
|
||||
self.add_httpd_log_modules(["auth_digest", "authn_file", "authz_core"])
|
||||
self._digest_pwfile = os.path.join(self.server_dir, "digest.passwd")
|
||||
|
||||
def setup_httpd(self, setup: HttpdTestSetup = None):
|
||||
super().setup_httpd(setup=AAATestSetup(env=self))
|
||||
self._write_digest_pwfile()
|
||||
|
||||
def _write_digest_pwfile(self):
|
||||
def ha1(user, password):
|
||||
return hashlib.md5(
|
||||
f"{user}:{self.REALM}:{password}".encode()).hexdigest()
|
||||
|
||||
with open(self._digest_pwfile, 'w') as fd:
|
||||
fd.write(f"{self.DIGEST_USER}:{self.REALM}:"
|
||||
f"{ha1(self.DIGEST_USER, self.DIGEST_PASSWORD)}\n")
|
||||
fd.write(f"{self.DIGEST_USER2}:{self.REALM}:"
|
||||
f"{ha1(self.DIGEST_USER2, self.DIGEST_PASSWORD2)}\n")
|
||||
|
||||
@property
|
||||
def digest_pwfile(self) -> str:
|
||||
return self._digest_pwfile
|
||||
|
||||
def configtest(self, directory_lines: List[str], extra_top_lines: Optional[List[str]] = None
|
||||
) -> ExecResult:
|
||||
"""Run `httpd -t` against a minimal, standalone config built from the
|
||||
already-generated modules.conf plus `directory_lines` wrapped in a
|
||||
<Directory> block over the shared docroot. Used to test directives
|
||||
that are rejected at config-check time (e.g. AuthDigestQop values
|
||||
other than 'auth') without touching the package's running server.
|
||||
"""
|
||||
conf_path = os.path.join(self.gen_dir, "digest-configtest.conf")
|
||||
modules_conf = os.path.join(self.server_conf_dir, "modules.conf")
|
||||
lines = [
|
||||
f'ServerRoot "{self.server_dir}"',
|
||||
f'Include "{modules_conf}"',
|
||||
f'DocumentRoot "{self.server_docs_dir}"',
|
||||
f'Listen {self.http_port2}',
|
||||
]
|
||||
if extra_top_lines:
|
||||
lines.extend(extra_top_lines)
|
||||
lines.append(f'<Directory "{self.server_docs_dir}">')
|
||||
lines.extend(f" {l}" for l in directory_lines)
|
||||
lines.append('</Directory>')
|
||||
with open(conf_path, 'w') as fd:
|
||||
fd.write('\n'.join(lines))
|
||||
fd.write('\n')
|
||||
httpd_bin = os.path.join(self.bin_dir, 'httpd')
|
||||
return self.run([httpd_bin, '-t', '-f', conf_path])
|
||||
1
test/modules/aaa/htdocs/digest/default/secret.txt
Normal file
1
test/modules/aaa/htdocs/digest/default/secret.txt
Normal file
@@ -0,0 +1 @@
|
||||
digest-default-secret
|
||||
1
test/modules/aaa/htdocs/digest/domain/nested/secret.txt
Normal file
1
test/modules/aaa/htdocs/digest/domain/nested/secret.txt
Normal file
@@ -0,0 +1 @@
|
||||
digest-domain-nested-secret
|
||||
1
test/modules/aaa/htdocs/digest/domain/secret.txt
Normal file
1
test/modules/aaa/htdocs/digest/domain/secret.txt
Normal file
@@ -0,0 +1 @@
|
||||
digest-domain-secret
|
||||
1
test/modules/aaa/htdocs/digest/nccheck/secret.txt
Normal file
1
test/modules/aaa/htdocs/digest/nccheck/secret.txt
Normal file
@@ -0,0 +1 @@
|
||||
digest-nccheck-secret
|
||||
1
test/modules/aaa/htdocs/digest/neverexpire/secret.txt
Normal file
1
test/modules/aaa/htdocs/digest/neverexpire/secret.txt
Normal file
@@ -0,0 +1 @@
|
||||
digest-neverexpire-secret
|
||||
1
test/modules/aaa/htdocs/digest/noprovider/secret.txt
Normal file
1
test/modules/aaa/htdocs/digest/noprovider/secret.txt
Normal file
@@ -0,0 +1 @@
|
||||
digest-noprovider-secret
|
||||
1
test/modules/aaa/htdocs/digest/onetime/secret.txt
Normal file
1
test/modules/aaa/htdocs/digest/onetime/secret.txt
Normal file
@@ -0,0 +1 @@
|
||||
digest-onetime-secret
|
||||
1
test/modules/aaa/htdocs/digest/shortlife/secret.txt
Normal file
1
test/modules/aaa/htdocs/digest/shortlife/secret.txt
Normal file
@@ -0,0 +1 @@
|
||||
digest-shortlife-secret
|
||||
180
test/modules/aaa/test_001_challenge_response.py
Normal file
180
test/modules/aaa/test_001_challenge_response.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""RFC 2617 Digest challenge/response scenarios against mod_auth_digest's
|
||||
default configuration (AuthDigestProvider file, AuthDigestQop auth (the only
|
||||
supported value), AuthDigestNonceLifetime 300, no AuthDigestDomain).
|
||||
"""
|
||||
|
||||
from . import digest_client as dc
|
||||
from .env import AAATestEnv
|
||||
|
||||
|
||||
class TestDigestChallengeResponse:
|
||||
|
||||
def url(self, env, path="secret.txt", location="default"):
|
||||
return env.mkurl("http", "aaa", f"/digest/{location}/{path}")
|
||||
|
||||
def challenge(self, env, location="default"):
|
||||
r = env.curl_get(self.url(env, location=location))
|
||||
assert r.response["status"] == 401
|
||||
return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
|
||||
|
||||
def test_digest_001_no_credentials(self, env):
|
||||
# No Authorization header at all -> 401 with a well-formed challenge.
|
||||
r = env.curl_get(self.url(env))
|
||||
assert r.response["status"] == 401
|
||||
auth = r.response["header"]["www-authenticate"]
|
||||
challenge = dc.DigestChallenge.parse(auth)
|
||||
assert challenge.realm == AAATestEnv.REALM
|
||||
assert challenge.algorithm == "MD5"
|
||||
assert challenge.qop == "auth"
|
||||
assert challenge.stale is False
|
||||
# no AuthDigestDomain configured for this Location -> no domain=
|
||||
assert challenge.domain is None
|
||||
# nonce-count checking is off and lifetime isn't 0 here, so the
|
||||
# server has no reason to track this client -> no opaque=
|
||||
assert challenge.opaque is None
|
||||
|
||||
def test_digest_002_success(self, env):
|
||||
challenge = self.challenge(env)
|
||||
auth = dc.build_authorization(
|
||||
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
|
||||
method="GET", uri="/digest/default/secret.txt")
|
||||
r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
|
||||
assert r.response["status"] == 200
|
||||
assert r.response["body"].decode() == "digest-default-secret\n"
|
||||
|
||||
def test_digest_003_rspauth(self, env):
|
||||
# Authentication-Info's rspauth= must match what we independently
|
||||
# compute from the same HA1 -- proves the server round-trips the
|
||||
# session parameters (nonce/nc/cnonce/qop) correctly.
|
||||
challenge = self.challenge(env)
|
||||
nc = "00000001"
|
||||
cnonce = "test-cnonce-rspauth"
|
||||
uri = "/digest/default/secret.txt"
|
||||
auth = dc.build_authorization(
|
||||
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
|
||||
method="GET", uri=uri, nc=nc, cnonce=cnonce)
|
||||
r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
|
||||
assert r.response["status"] == 200
|
||||
ai = dc.parse_params(r.response["header"]["authentication-info"])
|
||||
h1 = dc.ha1(AAATestEnv.DIGEST_USER, challenge.realm, AAATestEnv.DIGEST_PASSWORD)
|
||||
expected = dc.rspauth_digest(h1, challenge.nonce, nc, cnonce, "auth", uri)
|
||||
assert ai["rspauth"] == expected
|
||||
assert ai["qop"] == "auth"
|
||||
assert ai["nc"] == nc
|
||||
assert ai["cnonce"] == cnonce
|
||||
|
||||
def test_digest_004_wrong_password(self, env):
|
||||
challenge = self.challenge(env)
|
||||
auth = dc.build_authorization(
|
||||
AAATestEnv.DIGEST_USER, challenge, "not-the-password",
|
||||
method="GET", uri="/digest/default/secret.txt")
|
||||
r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
|
||||
assert r.response["status"] == 401
|
||||
env.httpd_error_log.ignore_recent(lognos=["AH01794"])
|
||||
|
||||
def test_digest_005_unknown_user(self, env):
|
||||
challenge = self.challenge(env)
|
||||
auth = dc.build_authorization(
|
||||
"no-such-user", challenge, "whatever",
|
||||
method="GET", uri="/digest/default/secret.txt")
|
||||
r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
|
||||
assert r.response["status"] == 401
|
||||
env.httpd_error_log.ignore_recent(lognos=["AH01790"])
|
||||
|
||||
def test_digest_006_second_user(self, env):
|
||||
# a distinct user in the same password file also works
|
||||
challenge = self.challenge(env)
|
||||
auth = dc.build_authorization(
|
||||
AAATestEnv.DIGEST_USER2, challenge, AAATestEnv.DIGEST_PASSWORD2,
|
||||
method="GET", uri="/digest/default/secret.txt")
|
||||
r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
|
||||
assert r.response["status"] == 200
|
||||
|
||||
def test_digest_007_wrong_realm(self, env):
|
||||
challenge = self.challenge(env)
|
||||
auth = dc.build_authorization(
|
||||
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
|
||||
method="GET", uri="/digest/default/secret.txt",
|
||||
realm="Some Other Realm")
|
||||
r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
|
||||
assert r.response["status"] == 401
|
||||
env.httpd_error_log.ignore_recent(lognos=["AH01788"])
|
||||
|
||||
def test_digest_008_bad_algorithm_token(self, env):
|
||||
# a client claiming an algorithm other than MD5 is rejected outright,
|
||||
# even though the response hash below is computed correctly for MD5.
|
||||
challenge = self.challenge(env)
|
||||
auth = dc.build_authorization(
|
||||
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
|
||||
method="GET", uri="/digest/default/secret.txt",
|
||||
algorithm="MD5-sess")
|
||||
r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
|
||||
assert r.response["status"] == 401
|
||||
env.httpd_error_log.ignore_recent(lognos=["AH01789"])
|
||||
|
||||
def test_digest_009_legacy_no_qop_rejected(self, env):
|
||||
# RFC 2069-style digest (no qop/cnonce/nc) is syntactically valid but
|
||||
# explicitly no longer supported by this module.
|
||||
challenge = self.challenge(env)
|
||||
auth = dc.build_authorization(
|
||||
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
|
||||
method="GET", uri="/digest/default/secret.txt",
|
||||
qop=None, include_qop_fields=False)
|
||||
r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
|
||||
assert r.response["status"] == 401
|
||||
env.httpd_error_log.ignore_recent(lognos=["AH10560"])
|
||||
|
||||
def test_digest_010_malformed_header_missing_field(self, env):
|
||||
# missing "uri" entirely -> header is syntactically INVALID, so the
|
||||
# server issues a fresh (non-stale) challenge rather than evaluating
|
||||
# the (nonexistent) response hash.
|
||||
challenge = self.challenge(env)
|
||||
h1 = dc.ha1(AAATestEnv.DIGEST_USER, challenge.realm, AAATestEnv.DIGEST_PASSWORD)
|
||||
auth = ('Digest username="digestuser", '
|
||||
f'realm="{challenge.realm}", nonce="{challenge.nonce}", '
|
||||
f'response="{h1}", qop=auth, nc=00000001, cnonce="x"')
|
||||
r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
|
||||
assert r.response["status"] == 401
|
||||
new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
|
||||
assert new_challenge.stale is False
|
||||
env.httpd_error_log.ignore_recent(lognos=["AH01782"])
|
||||
|
||||
def test_digest_011_wrong_scheme(self, env):
|
||||
r = env.curl_get(self.url(env), options=[
|
||||
"-H", "Authorization: Basic ZGlnZXN0dXNlcjpkaWdlc3RwYXNz"])
|
||||
assert r.response["status"] == 401
|
||||
env.httpd_error_log.ignore_recent(lognos=["AH01781"])
|
||||
|
||||
def test_digest_012_uri_mismatch(self, env):
|
||||
# The Authorization uri= must match the actual request-target; a
|
||||
# self-consistent response computed for a *different* uri than the
|
||||
# one actually requested is rejected as a bad request, before the
|
||||
# hash is even checked.
|
||||
challenge = self.challenge(env)
|
||||
other_uri = "/digest/default/other-secret.txt"
|
||||
auth = dc.build_authorization(
|
||||
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
|
||||
method="GET", uri=other_uri)
|
||||
r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
|
||||
assert r.response["status"] == 400
|
||||
env.httpd_error_log.ignore_recent(lognos=["AH01786"])
|
||||
|
||||
def test_digest_013_invalid_opaque(self, env):
|
||||
challenge = self.challenge(env)
|
||||
auth = dc.build_authorization(
|
||||
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
|
||||
method="GET", uri="/digest/default/secret.txt",
|
||||
opaque="not-a-hex-number")
|
||||
r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
|
||||
assert r.response["status"] == 401
|
||||
env.httpd_error_log.ignore_recent(lognos=["AH01787"])
|
||||
|
||||
def test_digest_014_tampered_response_hash(self, env):
|
||||
challenge = self.challenge(env)
|
||||
auth = dc.build_authorization(
|
||||
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
|
||||
method="GET", uri="/digest/default/secret.txt",
|
||||
response="0" * 32)
|
||||
r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
|
||||
assert r.response["status"] == 401
|
||||
env.httpd_error_log.ignore_recent(lognos=["AH01794"])
|
||||
129
test/modules/aaa/test_002_nonce.py
Normal file
129
test/modules/aaa/test_002_nonce.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Nonce lifecycle scenarios: tampered nonces, AuthDigestNonceLifetime
|
||||
expiry/reissue, a never-expiring nonce, and the one-time-nonce
|
||||
(AuthDigestNonceLifetime 0) case.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from . import digest_client as dc
|
||||
from .env import AAATestEnv
|
||||
|
||||
|
||||
class TestDigestNonce:
|
||||
|
||||
def url(self, env, location, path="secret.txt"):
|
||||
return env.mkurl("http", "aaa", f"/digest/{location}/{path}")
|
||||
|
||||
def challenge(self, env, location):
|
||||
r = env.curl_get(self.url(env, location))
|
||||
assert r.response["status"] == 401
|
||||
return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
|
||||
|
||||
def authenticate(self, env, location, challenge, nc="00000001",
|
||||
cnonce="nonce-test-cnonce", uri=None):
|
||||
uri = uri or f"/digest/{location}/secret.txt"
|
||||
auth = dc.build_authorization(
|
||||
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
|
||||
method="GET", uri=uri, nc=nc, cnonce=cnonce)
|
||||
return env.curl_get(self.url(env, location), options=["-H", f"Authorization: {auth}"])
|
||||
|
||||
def test_digest_020_tampered_nonce_is_stale(self, env):
|
||||
challenge = self.challenge(env, "default")
|
||||
# flip a character in the middle of the opaque nonce blob: it stays
|
||||
# the right length but its embedded hash no longer verifies.
|
||||
bad = list(challenge.nonce)
|
||||
mid = len(bad) // 2
|
||||
bad[mid] = 'x' if bad[mid] != 'x' else 'y'
|
||||
challenge.nonce = ''.join(bad)
|
||||
r = self.authenticate(env, "default", challenge)
|
||||
assert r.response["status"] == 401
|
||||
new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
|
||||
assert new_challenge.stale is True
|
||||
env.httpd_error_log.ignore_recent(lognos=["AH01776"])
|
||||
|
||||
def test_digest_021_garbage_nonce_hash_is_stale(self, env):
|
||||
# A nonce must still look like "b64(time)+sha1hex(hash)" (VALID_NONCE
|
||||
# in mod_auth_digest.c checks length and the '=' padding boundary) to
|
||||
# even be considered for a hash check; something that doesn't match
|
||||
# that shape is instead rejected as a malformed header (see
|
||||
# test_digest_010). Here we keep the genuine time-prefix (so the
|
||||
# shape is valid) but replace the whole hash suffix with garbage, to
|
||||
# hit check_nonce()'s "hash is not %s" path distinctly from
|
||||
# test_digest_020's single-flipped-character tamper.
|
||||
challenge = self.challenge(env, "default")
|
||||
time_prefix = challenge.nonce[:-40]
|
||||
challenge.nonce = time_prefix + ("f" * 40)
|
||||
r = self.authenticate(env, "default", challenge)
|
||||
assert r.response["status"] == 401
|
||||
new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
|
||||
assert new_challenge.stale is True
|
||||
env.httpd_error_log.ignore_recent(lognos=["AH01776"])
|
||||
|
||||
def test_digest_022_short_lifetime_expires(self, env):
|
||||
# AuthDigestNonceLifetime 2 for this location.
|
||||
challenge = self.challenge(env, "shortlife")
|
||||
r = self.authenticate(env, "shortlife", challenge)
|
||||
assert r.response["status"] == 200
|
||||
|
||||
time.sleep(3)
|
||||
# same nonce, now past its lifetime -> 401 stale=true
|
||||
r = self.authenticate(env, "shortlife", challenge)
|
||||
assert r.response["status"] == 401
|
||||
stale_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
|
||||
assert stale_challenge.stale is True
|
||||
env.httpd_error_log.ignore_recent(lognos=["AH01776"])
|
||||
|
||||
# the fresh nonce from the stale challenge works again
|
||||
r = self.authenticate(env, "shortlife", stale_challenge)
|
||||
assert r.response["status"] == 200
|
||||
|
||||
def test_digest_023_never_expiring_nonce(self, env):
|
||||
# AuthDigestNonceLifetime -1 for this location: no NcCheck is
|
||||
# configured, so the identical Authorization line can simply be
|
||||
# replayed after a delay and must still succeed both times.
|
||||
challenge = self.challenge(env, "neverexpire")
|
||||
r1 = self.authenticate(env, "neverexpire", challenge)
|
||||
assert r1.response["status"] == 200
|
||||
|
||||
time.sleep(3)
|
||||
r2 = self.authenticate(env, "neverexpire", challenge)
|
||||
assert r2.response["status"] == 200
|
||||
|
||||
def test_digest_024_one_time_nonce_rejects_reuse(self, env):
|
||||
# AuthDigestNonceLifetime 0: a successful request immediately
|
||||
# supersedes its nonce (the tracked "last_nonce" moves on to the
|
||||
# nextnonce from Authentication-Info), so replaying the very same
|
||||
# nonce right afterwards must fail as stale. Each request against
|
||||
# this client (success OR failure) advances the tracked nonce again,
|
||||
# so this test does exactly one success followed by exactly one
|
||||
# reuse -- no longer chain that would need to account for that.
|
||||
challenge = self.challenge(env, "onetime")
|
||||
assert challenge.opaque is not None, \
|
||||
"one-time-nonce tracking requires an opaque to identify the client"
|
||||
|
||||
r1 = self.authenticate(env, "onetime", challenge)
|
||||
assert r1.response["status"] == 200
|
||||
ai1 = dc.parse_params(r1.response["header"]["authentication-info"])
|
||||
assert "nextnonce" in ai1
|
||||
assert ai1["nextnonce"] != challenge.nonce
|
||||
|
||||
# reusing the exact same (now superseded) nonce fails as stale
|
||||
r2 = self.authenticate(env, "onetime", challenge)
|
||||
assert r2.response["status"] == 401
|
||||
stale_challenge = dc.DigestChallenge.parse(r2.response["header"]["www-authenticate"])
|
||||
assert stale_challenge.stale is True
|
||||
env.httpd_error_log.ignore_recent(lognos=["AH01776"])
|
||||
|
||||
def test_digest_025_one_time_nonce_chain_continues(self, env):
|
||||
# Following the nextnonce handed out on a successful response lets
|
||||
# the client keep authenticating, one hop at a time.
|
||||
challenge = self.challenge(env, "onetime")
|
||||
r1 = self.authenticate(env, "onetime", challenge)
|
||||
assert r1.response["status"] == 200
|
||||
ai1 = dc.parse_params(r1.response["header"]["authentication-info"])
|
||||
|
||||
challenge.nonce = ai1["nextnonce"]
|
||||
r2 = self.authenticate(env, "onetime", challenge)
|
||||
assert r2.response["status"] == 200
|
||||
ai2 = dc.parse_params(r2.response["header"]["authentication-info"])
|
||||
assert ai2["nextnonce"] != ai1["nextnonce"]
|
||||
99
test/modules/aaa/test_003_nccheck.py
Normal file
99
test/modules/aaa/test_003_nccheck.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""AuthDigestNcCheck replay-detection scenarios.
|
||||
|
||||
Note the actual semantics here are stricter than a sliding replay window:
|
||||
the server keeps its own count of authenticated requests seen for a client
|
||||
(incremented on *every* request carrying that client's opaque, whether or
|
||||
not it goes on to authenticate) and requires the client's nc to match it
|
||||
*exactly* -- so both replays of an old nc and skipping ahead are rejected.
|
||||
A failed nc check also resets the server's tracked count back to 0, as part
|
||||
of issuing a fresh challenge for the client (see note_digest_auth_failure()
|
||||
in mod_auth_digest.c: an existing, opaque-identified client always gets its
|
||||
nonce_count reset when a new challenge is generated for it, regardless of
|
||||
*why* the challenge is being reissued) -- so recovery after a rejected nc
|
||||
means starting the sequence over at 00000001, not continuing where the
|
||||
client left off.
|
||||
"""
|
||||
|
||||
from . import digest_client as dc
|
||||
from .env import AAATestEnv
|
||||
|
||||
|
||||
class TestDigestNcCheck:
|
||||
|
||||
def url(self, env, location, path="secret.txt"):
|
||||
return env.mkurl("http", "aaa", f"/digest/{location}/{path}")
|
||||
|
||||
def challenge(self, env, location):
|
||||
r = env.curl_get(self.url(env, location))
|
||||
assert r.response["status"] == 401
|
||||
return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
|
||||
|
||||
def authenticate(self, env, location, challenge, nc, cnonce="ncc-test-cnonce",
|
||||
include_opaque=True):
|
||||
uri = f"/digest/{location}/secret.txt"
|
||||
auth = dc.build_authorization(
|
||||
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
|
||||
method="GET", uri=uri, nc=nc, cnonce=cnonce,
|
||||
include_opaque=include_opaque)
|
||||
return env.curl_get(self.url(env, location), options=["-H", f"Authorization: {auth}"])
|
||||
|
||||
def test_digest_030_nccheck_requires_opaque(self, env):
|
||||
# with AuthDigestNcCheck on, the server cannot verify nc without
|
||||
# having tracked this client via its opaque -- omitting the opaque
|
||||
# therefore fails the check outright, even with nc=00000001.
|
||||
challenge = self.challenge(env, "nccheck")
|
||||
assert challenge.opaque is not None
|
||||
r = self.authenticate(env, "nccheck", challenge, nc="00000001", include_opaque=False)
|
||||
assert r.response["status"] == 401
|
||||
new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
|
||||
assert new_challenge.stale is False
|
||||
|
||||
def test_digest_031_nccheck_sequential_ok(self, env):
|
||||
challenge = self.challenge(env, "nccheck")
|
||||
r1 = self.authenticate(env, "nccheck", challenge, nc="00000001")
|
||||
assert r1.response["status"] == 200
|
||||
r2 = self.authenticate(env, "nccheck", challenge, nc="00000002")
|
||||
assert r2.response["status"] == 200
|
||||
r3 = self.authenticate(env, "nccheck", challenge, nc="00000003")
|
||||
assert r3.response["status"] == 200
|
||||
|
||||
def test_digest_032_nccheck_replay_rejected(self, env):
|
||||
challenge = self.challenge(env, "nccheck")
|
||||
r1 = self.authenticate(env, "nccheck", challenge, nc="00000001")
|
||||
assert r1.response["status"] == 200
|
||||
r2 = self.authenticate(env, "nccheck", challenge, nc="00000002")
|
||||
assert r2.response["status"] == 200
|
||||
|
||||
# replay an already-used nc -> rejected, and NOT reported as stale
|
||||
# (this is a distinct failure mode from an invalid/expired nonce).
|
||||
r3 = self.authenticate(env, "nccheck", challenge, nc="00000001")
|
||||
assert r3.response["status"] == 401
|
||||
new_challenge = dc.DigestChallenge.parse(r3.response["header"]["www-authenticate"])
|
||||
assert new_challenge.stale is False
|
||||
env.httpd_error_log.ignore_recent(lognos=["AH01774"])
|
||||
|
||||
# the rejected attempt reset the server's tracked count to 0 (a new
|
||||
# challenge was issued for this client), so recovery restarts the
|
||||
# sequence at 00000001 -- continuing from 00000003 would NOT work.
|
||||
r4 = self.authenticate(env, "nccheck", challenge, nc="00000001")
|
||||
assert r4.response["status"] == 200
|
||||
|
||||
def test_digest_033_nccheck_skip_ahead_rejected(self, env):
|
||||
challenge = self.challenge(env, "nccheck")
|
||||
r1 = self.authenticate(env, "nccheck", challenge, nc="00000001")
|
||||
assert r1.response["status"] == 200
|
||||
|
||||
# skipping ahead is rejected too: nc must match exactly, not just
|
||||
# be higher than what was last accepted.
|
||||
r2 = self.authenticate(env, "nccheck", challenge, nc="00000009")
|
||||
assert r2.response["status"] == 401
|
||||
env.httpd_error_log.ignore_recent(lognos=["AH01774"])
|
||||
|
||||
def test_digest_034_no_nccheck_allows_replay(self, env):
|
||||
# the "default" location has no AuthDigestNcCheck (Off by default),
|
||||
# so replaying the exact same nc is not detected or rejected.
|
||||
challenge = self.challenge(env, "default")
|
||||
r1 = self.authenticate(env, "default", challenge, nc="00000001")
|
||||
assert r1.response["status"] == 200
|
||||
r2 = self.authenticate(env, "default", challenge, nc="00000001")
|
||||
assert r2.response["status"] == 200
|
||||
56
test/modules/aaa/test_004_domain.py
Normal file
56
test/modules/aaa/test_004_domain.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""AuthDigestDomain: presence, format, and inheritance of the domain=
|
||||
attribute in the WWW-Authenticate challenge.
|
||||
"""
|
||||
|
||||
from . import digest_client as dc
|
||||
from .env import AAATestEnv
|
||||
|
||||
|
||||
class TestDigestDomain:
|
||||
|
||||
def url(self, env, path):
|
||||
return env.mkurl("http", "aaa", path)
|
||||
|
||||
def test_digest_040_domain_attribute_present(self, env):
|
||||
r = env.curl_get(self.url(env, "/digest/domain/secret.txt"))
|
||||
assert r.response["status"] == 401
|
||||
challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
|
||||
# set_uri_list() (mod_auth_digest.c) builds a single quoted,
|
||||
# space-separated list from the configured AuthDigestDomain URIs.
|
||||
assert challenge.domain == "/digest/domain/ https://mirror.example.org/other/"
|
||||
assert challenge.domain_list() == [
|
||||
"/digest/domain/", "https://mirror.example.org/other/"]
|
||||
|
||||
def test_digest_041_no_domain_configured_omits_attribute(self, env):
|
||||
r = env.curl_get(self.url(env, "/digest/default/secret.txt"))
|
||||
assert r.response["status"] == 401
|
||||
challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
|
||||
assert challenge.domain is None
|
||||
|
||||
def test_digest_042_domain_location_still_authenticates(self, env):
|
||||
r = env.curl_get(self.url(env, "/digest/domain/secret.txt"))
|
||||
challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
|
||||
auth = dc.build_authorization(
|
||||
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
|
||||
method="GET", uri="/digest/domain/secret.txt")
|
||||
r = env.curl_get(self.url(env, "/digest/domain/secret.txt"),
|
||||
options=["-H", f"Authorization: {auth}"])
|
||||
assert r.response["status"] == 200
|
||||
assert r.response["body"].decode() == "digest-domain-secret\n"
|
||||
|
||||
def test_digest_043_domain_inherited_by_nested_path(self, env):
|
||||
# AuthDigestDomain is set on /digest/domain/; a path nested below it
|
||||
# inherits the same directory config (same realm/credentials/domain).
|
||||
r = env.curl_get(self.url(env, "/digest/domain/nested/secret.txt"))
|
||||
assert r.response["status"] == 401
|
||||
challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
|
||||
assert challenge.realm == AAATestEnv.REALM
|
||||
assert challenge.domain == "/digest/domain/ https://mirror.example.org/other/"
|
||||
|
||||
auth = dc.build_authorization(
|
||||
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
|
||||
method="GET", uri="/digest/domain/nested/secret.txt")
|
||||
r = env.curl_get(self.url(env, "/digest/domain/nested/secret.txt"),
|
||||
options=["-H", f"Authorization: {auth}"])
|
||||
assert r.response["status"] == 200
|
||||
assert r.response["body"].decode() == "digest-domain-nested-secret\n"
|
||||
37
test/modules/aaa/test_005_provider.py
Normal file
37
test/modules/aaa/test_005_provider.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""AuthDigestProvider scenarios."""
|
||||
|
||||
from . import digest_client as dc
|
||||
from .env import AAATestEnv
|
||||
|
||||
|
||||
class TestDigestProvider:
|
||||
|
||||
def url(self, env, path):
|
||||
return env.mkurl("http", "aaa", path)
|
||||
|
||||
def test_digest_050_omitted_provider_defaults_to_file(self, env):
|
||||
# /digest/noprovider/ has no AuthDigestProvider directive at all;
|
||||
# mod_auth_digest falls back to the "file" provider (mod_authn_file)
|
||||
# by default (see get_hash() / AUTHN_DEFAULT_PROVIDER in mod_auth.h).
|
||||
path = "/digest/noprovider/secret.txt"
|
||||
r = env.curl_get(self.url(env, path))
|
||||
assert r.response["status"] == 401
|
||||
challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
|
||||
|
||||
auth = dc.build_authorization(
|
||||
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
|
||||
method="GET", uri=path)
|
||||
r = env.curl_get(self.url(env, path), options=["-H", f"Authorization: {auth}"])
|
||||
assert r.response["status"] == 200
|
||||
assert r.response["body"].decode() == "digest-noprovider-secret\n"
|
||||
|
||||
def test_digest_051_unknown_provider_rejected_at_config_time(self, env):
|
||||
r = env.configtest([
|
||||
'AuthType Digest',
|
||||
f'AuthName "{AAATestEnv.REALM}"',
|
||||
'AuthDigestProvider no-such-provider',
|
||||
f'AuthUserFile "{env.digest_pwfile}"',
|
||||
'Require valid-user',
|
||||
])
|
||||
assert r.exit_code != 0
|
||||
assert "Unknown Authn provider" in r.stderr
|
||||
86
test/modules/aaa/test_006_config_errors.py
Normal file
86
test/modules/aaa/test_006_config_errors.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Config-time validation for directives whose *documented* syntax (see
|
||||
docs/manual/mod/mod_auth_digest.xml) is broader than what this build's
|
||||
mod_auth_digest.c actually implements: AuthDigestQop only accepts "auth"
|
||||
(qop=none/auth-int are rejected -- the "Open Issues" comment in the source
|
||||
notes MD5-sess and auth-int were removed as incomplete), AuthDigestAlgorithm
|
||||
only accepts "MD5", and AuthDigestShmemSize enforces a minimum size. These
|
||||
are all checked with `httpd -t` against a throwaway config so the shared
|
||||
package server is never disturbed.
|
||||
"""
|
||||
|
||||
from .env import AAATestEnv
|
||||
|
||||
|
||||
class TestDigestConfigErrors:
|
||||
|
||||
def test_digest_060_qop_none_rejected(self, env):
|
||||
r = env.configtest([
|
||||
'AuthType Digest',
|
||||
f'AuthName "{AAATestEnv.REALM}"',
|
||||
'AuthDigestProvider file',
|
||||
f'AuthUserFile "{env.digest_pwfile}"',
|
||||
'AuthDigestQop none',
|
||||
'Require valid-user',
|
||||
])
|
||||
assert r.exit_code != 0
|
||||
assert "AuthDigestQop" in r.stderr
|
||||
|
||||
def test_digest_061_qop_auth_int_rejected(self, env):
|
||||
r = env.configtest([
|
||||
'AuthType Digest',
|
||||
f'AuthName "{AAATestEnv.REALM}"',
|
||||
'AuthDigestProvider file',
|
||||
f'AuthUserFile "{env.digest_pwfile}"',
|
||||
'AuthDigestQop auth-int',
|
||||
'Require valid-user',
|
||||
])
|
||||
assert r.exit_code != 0
|
||||
assert "AuthDigestQop" in r.stderr
|
||||
|
||||
def test_digest_062_qop_auth_accepted(self, env):
|
||||
# the only value actually supported must still work.
|
||||
r = env.configtest([
|
||||
'AuthType Digest',
|
||||
f'AuthName "{AAATestEnv.REALM}"',
|
||||
'AuthDigestProvider file',
|
||||
f'AuthUserFile "{env.digest_pwfile}"',
|
||||
'AuthDigestQop auth',
|
||||
'Require valid-user',
|
||||
])
|
||||
assert r.exit_code == 0
|
||||
|
||||
def test_digest_063_algorithm_md5_sess_rejected(self, env):
|
||||
r = env.configtest([
|
||||
'AuthType Digest',
|
||||
f'AuthName "{AAATestEnv.REALM}"',
|
||||
'AuthDigestProvider file',
|
||||
f'AuthUserFile "{env.digest_pwfile}"',
|
||||
'AuthDigestAlgorithm MD5-sess',
|
||||
'Require valid-user',
|
||||
])
|
||||
assert r.exit_code != 0
|
||||
assert "Unsupported algorithm" in r.stderr
|
||||
|
||||
def test_digest_064_algorithm_md5_accepted(self, env):
|
||||
r = env.configtest([
|
||||
'AuthType Digest',
|
||||
f'AuthName "{AAATestEnv.REALM}"',
|
||||
'AuthDigestProvider file',
|
||||
f'AuthUserFile "{env.digest_pwfile}"',
|
||||
'AuthDigestAlgorithm MD5',
|
||||
'Require valid-user',
|
||||
])
|
||||
assert r.exit_code == 0
|
||||
|
||||
def test_digest_065_shmemsize_too_small_rejected(self, env):
|
||||
r = env.configtest([], extra_top_lines=["AuthDigestShmemSize 10"])
|
||||
assert r.exit_code != 0
|
||||
assert "AuthDigestShmemSize" in r.stderr
|
||||
|
||||
def test_digest_066_shmemsize_valid_accepted(self, env):
|
||||
r = env.configtest([], extra_top_lines=["AuthDigestShmemSize 1000"])
|
||||
assert r.exit_code == 0
|
||||
|
||||
def test_digest_067_shmemsize_units_accepted(self, env):
|
||||
r = env.configtest([], extra_top_lines=["AuthDigestShmemSize 64K"])
|
||||
assert r.exit_code == 0
|
||||
Reference in New Issue
Block a user