authentication config: add optional max_validity_time, skew

The Zuul admin can configure authenticators with an optional
"max_validity_time" field, which is the maximum age in seconds
for a valid authentication token. By default there is no
maximum age set for tokens, except the one deduced from
the token's "exp" claim.
If "max_validity" is set, tokens without an "iat" claim will
be rejected.

This is meant as an extra security to avoid accidentally issueing
very long lived tokens through the CLI.

The "skew" field can be used to mitigate clocks discrepancies
between Zuul and a JWT emitter.

Change-Id: I9351ca016b60050b5f3b3950b840d5f719e919ce
This commit is contained in:
Matthieu Huin 2019-03-11 11:18:48 +01:00
parent a0015014c9
commit b599c7249d
7 changed files with 236 additions and 3 deletions

View File

@ -991,6 +991,13 @@ protected endpoints and configure JWT validation:
of time in seconds. This is useful if the Zuul operator has no control of time in seconds. This is useful if the Zuul operator has no control
over the service issueing JWTs, and the tokens are too long-lived. over the service issueing JWTs, and the tokens are too long-lived.
.. attr:: skew
:default: 0
Optional integer value to compensate for skew between Zuul's and the
JWT emitter's respective clocks. Use a negative value if Zuul's clock
is running behind.
This section can be repeated as needed with different authenticators, allowing This section can be repeated as needed with different authenticators, allowing
access to privileged API actions from several JWT issuers. access to privileged API actions from several JWT issuers.

View File

@ -501,6 +501,8 @@ Here is an example defining the three supported types of authenticators:
realm=openstack realm=openstack
# (optional) Ensure a Token cannot be valid for longer than this amount of time, in seconds # (optional) Ensure a Token cannot be valid for longer than this amount of time, in seconds
max_validity_time = 1800000 max_validity_time = 1800000
# (optional) Account for skew between clocks, in seconds
skew = 3
# asymmetrical encryption # asymmetrical encryption
[auth "my_oidc_idp"] [auth "my_oidc_idp"]
@ -517,6 +519,8 @@ Here is an example defining the three supported types of authenticators:
realm=openstack realm=openstack
# (optional) Ensure a Token cannot be valid for longer than this amount of time, in seconds # (optional) Ensure a Token cannot be valid for longer than this amount of time, in seconds
max_validity_time = 1800000 max_validity_time = 1800000
# (optional) Account for skew between clocks, in seconds
skew = 3
# asymmetrical encryption using JWKS for validation # asymmetrical encryption using JWKS for validation
# The signing secret being known to the Identity Provider only, this # The signing secret being known to the Identity Provider only, this
@ -532,7 +536,8 @@ Here is an example defining the three supported types of authenticators:
uid_claim=name uid_claim=name
# Auth realm, used in 401 error messages # Auth realm, used in 401 error messages
realm=openstack realm=openstack
# (optional) Account for skew between clocks, in seconds
skew = 3
Implementation Implementation
============== ==============

View File

@ -52,6 +52,8 @@ default=true
client_id=zuul.example.com client_id=zuul.example.com
issuer_id=zuul_operator issuer_id=zuul_operator
secret=NoDanaOnlyZuul secret=NoDanaOnlyZuul
max_validity_time=36000
skew=0
[connection gerrit] [connection gerrit]
driver=gerrit driver=gerrit

View File

@ -0,0 +1,32 @@
[gearman]
server=127.0.0.1
[scheduler]
tenant_config=main.yaml
relative_priority=true
[merger]
git_dir=/tmp/zuul-test/merger-git
git_user_email=zuul@example.com
git_user_name=zuul
[executor]
git_dir=/tmp/zuul-test/executor-git
[connection gerrit]
driver=gerrit
server=review.example.com
user=jenkins
sshkey=fake_id_rsa_path
[web]
static_cache_expiry=1200
[auth zuul_operator]
driver=HS256
allow_authz_override=true
realm=zuul.example.com
client_id=zuul.example.com
issuer_id=zuul_operator
secret=NoDanaOnlyZuul
max_validity_time=5

View File

@ -1716,6 +1716,162 @@ class TestTenantScopedWebApiWithAuthRules(BaseTestWeb):
"%s got %s" % (authz['sub'], data)) "%s got %s" % (authz['sub'], data))
class TestTenantScopedWebApiTokenWithExpiry(BaseTestWeb):
config_file = 'zuul-admin-web-token-expiry.conf'
def test_iat_claim_mandatory(self):
"""Test that the 'iat' claim is mandatory when
max_validity_time is set"""
authz = {'iss': 'zuul_operator',
'sub': 'testuser',
'aud': 'zuul.example.com',
'zuul': {
'admin': ['tenant-one', ]
},
'exp': time.time() + 3600}
token = jwt.encode(authz, key='NoDanaOnlyZuul',
algorithm='HS256').decode('utf-8')
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/autohold",
headers={'Authorization': 'Bearer %s' % token},
json={'job': 'project-test1',
'count': 1,
'reason': 'because',
'node_hold_expiration': 36000})
self.assertEqual(401, resp.status_code)
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/enqueue",
headers={'Authorization': 'Bearer %s' % token},
json={'trigger': 'gerrit',
'change': '2,1',
'pipeline': 'check'})
self.assertEqual(401, resp.status_code)
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/enqueue",
headers={'Authorization': 'Bearer %s' % token},
json={'trigger': 'gerrit',
'ref': 'abcd',
'newrev': 'aaaa',
'oldrev': 'bbbb',
'pipeline': 'check'})
self.assertEqual(401, resp.status_code)
def test_token_from_the_future(self):
authz = {'iss': 'zuul_operator',
'sub': 'testuser',
'aud': 'zuul.example.com',
'zuul': {
'admin': ['tenant-one', ],
},
'exp': time.time() + 7200,
'iat': time.time() + 3600}
token = jwt.encode(authz, key='NoDanaOnlyZuul',
algorithm='HS256').decode('utf-8')
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/autohold",
headers={'Authorization': 'Bearer %s' % token},
json={'job': 'project-test1',
'count': 1,
'reason': 'because',
'node_hold_expiration': 36000})
self.assertEqual(401, resp.status_code)
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/enqueue",
headers={'Authorization': 'Bearer %s' % token},
json={'trigger': 'gerrit',
'change': '2,1',
'pipeline': 'check'})
self.assertEqual(401, resp.status_code)
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/enqueue",
headers={'Authorization': 'Bearer %s' % token},
json={'trigger': 'gerrit',
'ref': 'abcd',
'newrev': 'aaaa',
'oldrev': 'bbbb',
'pipeline': 'check'})
self.assertEqual(401, resp.status_code)
def test_token_expired(self):
authz = {'iss': 'zuul_operator',
'sub': 'testuser',
'aud': 'zuul.example.com',
'zuul': {
'admin': ['tenant-one', ],
},
'exp': time.time() + 3600,
'iat': time.time()}
token = jwt.encode(authz, key='NoDanaOnlyZuul',
algorithm='HS256').decode('utf-8')
time.sleep(10)
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/autohold",
headers={'Authorization': 'Bearer %s' % token},
json={'job': 'project-test1',
'count': 1,
'reason': 'because',
'node_hold_expiration': 36000})
self.assertEqual(401, resp.status_code)
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/enqueue",
headers={'Authorization': 'Bearer %s' % token},
json={'trigger': 'gerrit',
'change': '2,1',
'pipeline': 'check'})
self.assertEqual(401, resp.status_code)
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/enqueue",
headers={'Authorization': 'Bearer %s' % token},
json={'trigger': 'gerrit',
'ref': 'abcd',
'newrev': 'aaaa',
'oldrev': 'bbbb',
'pipeline': 'check'})
self.assertEqual(401, resp.status_code)
def test_autohold(self):
"""Test that autohold can be set through the admin web interface"""
args = {"reason": "some reason",
"count": 1,
'job': 'project-test2',
'change': None,
'ref': None,
'node_hold_expiration': None}
authz = {'iss': 'zuul_operator',
'aud': 'zuul.example.com',
'sub': 'testuser',
'zuul': {
'admin': ['tenant-one', ],
},
'exp': time.time() + 3600,
'iat': time.time()}
token = jwt.encode(authz, key='NoDanaOnlyZuul',
algorithm='HS256').decode('utf-8')
req = self.post_url(
'api/tenant/tenant-one/project/org/project/autohold',
headers={'Authorization': 'Bearer %s' % token},
json=args)
self.assertEqual(200, req.status_code, req.text)
data = req.json()
self.assertEqual(True, data)
# Check result in rpc client
client = zuul.rpcclient.RPCClient('127.0.0.1',
self.gearman_server.port)
self.addCleanup(client.shutdown)
autohold_requests = client.autohold_list()
self.assertNotEqual([], autohold_requests)
self.assertEqual(1, len(autohold_requests))
ah_request = autohold_requests[0]
self.assertEqual('tenant-one', ah_request['tenant'])
self.assertIn('org/project', ah_request['project'])
self.assertEqual('project-test2', ah_request['job'])
self.assertEqual(".*", ah_request['ref_filter'])
self.assertEqual("some reason", ah_request['reason'])
class TestWebMulti(BaseTestWeb): class TestWebMulti(BaseTestWeb):
config_file = 'zuul-gerrit-github.conf' config_file = 'zuul-gerrit-github.conf'

View File

@ -540,7 +540,9 @@ class Client(zuul.cmd.ZuulApp):
print('"%s" authenticator configuration not found.' print('"%s" authenticator configuration not found.'
% self.args.auth_config) % self.args.auth_config)
sys.exit(1) sys.exit(1)
token = {'exp': time.time() + self.args.expires_in, now = time.time()
token = {'iat': now,
'exp': now + self.args.expires_in,
'iss': get_default(self.config, auth_section, 'issuer_id'), 'iss': get_default(self.config, auth_section, 'issuer_id'),
'aud': get_default(self.config, auth_section, 'client_id'), 'aud': get_default(self.config, auth_section, 'client_id'),
'sub': self.args.user, 'sub': self.args.user,

View File

@ -14,6 +14,7 @@
# under the License. # under the License.
import logging import logging
import math
import time import time
import jwt import jwt
import requests import requests
@ -36,11 +37,21 @@ class JWTAuthenticator(AuthenticatorInterface):
self.audience = conf.get('client_id') self.audience = conf.get('client_id')
self.realm = conf.get('realm') self.realm = conf.get('realm')
self.allow_authz_override = conf.get('allow_authz_override', False) self.allow_authz_override = conf.get('allow_authz_override', False)
try:
self.skew = int(conf.get('skew', 0))
except Exception:
raise ValueError(
'skew must be an integer, got %s' % conf.get('skew'))
if isinstance(self.allow_authz_override, str): if isinstance(self.allow_authz_override, str):
if self.allow_authz_override.lower() == 'true': if self.allow_authz_override.lower() == 'true':
self.allow_authz_override = True self.allow_authz_override = True
else: else:
self.allow_authz_override = False self.allow_authz_override = False
try:
self.max_validity_time = float(conf.get('max_validity_time',
math.inf))
except ValueError:
raise ValueError('"max_validity_time" must be a numerical value')
def _decode(self, rawToken): def _decode(self, rawToken):
raise NotImplementedError raise NotImplementedError
@ -68,13 +79,31 @@ class JWTAuthenticator(AuthenticatorInterface):
raise exceptions.AuthTokenUnauthorizedException( raise exceptions.AuthTokenUnauthorizedException(
realm=self.realm, realm=self.realm,
msg=e) msg=e)
# Missing claim tests
if not all(x in decoded for x in ['aud', 'iss', 'exp', 'sub']): if not all(x in decoded for x in ['aud', 'iss', 'exp', 'sub']):
raise exceptions.MissingClaimError(realm=self.realm) raise exceptions.MissingClaimError(realm=self.realm)
if self.max_validity_time < math.inf and 'iat' not in decoded:
raise exceptions.MissingClaimError(
msg='Missing "iat" claim',
realm=self.realm)
if self.uid_claim not in decoded: if self.uid_claim not in decoded:
raise exceptions.MissingUIDClaimError(realm=self.realm) raise exceptions.MissingUIDClaimError(realm=self.realm)
# Time related tests
expires = decoded.get('exp', 0) expires = decoded.get('exp', 0)
if expires < time.time(): issued_at = decoded.get('iat', 0)
now = time.time()
if issued_at + self.skew > now:
raise exceptions.AuthTokenUnauthorizedException(
msg='"iat" claim set in the future',
realm=self.realm
)
if now - issued_at > self.max_validity_time:
raise exceptions.TokenExpiredError(
msg='Token was issued too long ago',
realm=self.realm)
if expires + self.skew < now:
raise exceptions.TokenExpiredError(realm=self.realm) raise exceptions.TokenExpiredError(realm=self.realm)
# Zuul-specific claims tests
zuul_claims = decoded.get('zuul', {}) zuul_claims = decoded.get('zuul', {})
admin_tenants = zuul_claims.get('admin', []) admin_tenants = zuul_claims.get('admin', [])
if not isinstance(admin_tenants, list): if not isinstance(admin_tenants, list):