Bug 1529223 - Auth: Use seconds not milliseconds for expiration handling

Previously the frontend would calculate the access token expiry timestamp
in milliseconds and pass it to the `/login/` API via an `ExpiresAt` header.
The backend would then convert both the Id Token's `exp` and current time
to milliseconds, when calculating the earliest expiry. The result then
had to be converted back to seconds for use with Django's session
`.set_expiry()`.

It is instead much simpler to leave everything in seconds, since none of
the Auth0-provided inputs were in milliseconds to start with, so there is
no loss of precision, just fewer conversions required. Timestamps are also
more commonly in seconds, so use of seconds is less surprising.

After this is deployed there will initially be users who have old frontend
pages open that are still sending the expiry as milliseconds. In order to
be able to differentiate between new and old clients, the header has been
renamed to `Access-Token-Expires-At` (which also makes it clearer as to
what the expiry is for, given there is also an Id Token expiry), and a
temporary fall-back added to the backend that can be removed after a few
days has passed.
This commit is contained in:
Ed Morley 2019-02-23 09:45:14 +00:00
Родитель 6c05a78eb4
Коммит e643b8e4e7
4 изменённых файлов: 181 добавлений и 38 удалений

Просмотреть файл

@ -62,6 +62,7 @@ def test_login_logout_relogin(client, monkeypatch, id_token_sub, id_token_email,
"""
now_in_seconds = int(time.time())
id_token_expiration_timestamp = now_in_seconds + one_day_in_seconds
access_token_expiration_timestamp = now_in_seconds + one_hour_in_seconds
def userinfo_mock(*args, **kwargs):
return {'sub': id_token_sub, 'email': id_token_email, 'exp': id_token_expiration_timestamp}
@ -71,10 +72,6 @@ def test_login_logout_relogin(client, monkeypatch, id_token_sub, id_token_email,
assert auth_session_key not in client.session
assert User.objects.count() == 0
# Confusingly the `ExpiresAt` header is expected to be in milliseconds.
# TODO: Change the frontend to pass seconds instead.
expires_at = (now_in_seconds + one_hour_in_seconds) * 1000
# The first time someone logs in a new user should be created,
# which is then associated with their Django session.
@ -82,7 +79,7 @@ def test_login_logout_relogin(client, monkeypatch, id_token_sub, id_token_email,
reverse('auth-login'),
HTTP_AUTHORIZATION='Bearer meh',
HTTP_IDTOKEN='meh',
HTTP_EXPIRESAT=str(expires_at)
HTTP_ACCESS_TOKEN_EXPIRES_AT=str(access_token_expiration_timestamp)
)
assert resp.status_code == 200
assert resp.json() == {
@ -113,7 +110,7 @@ def test_login_logout_relogin(client, monkeypatch, id_token_sub, id_token_email,
reverse('auth-login'),
HTTP_AUTHORIZATION='Bearer meh',
HTTP_IDTOKEN='meh',
HTTP_EXPIRESAT=str(expires_at)
HTTP_ACCESS_TOKEN_EXPIRES_AT=str(access_token_expiration_timestamp)
)
assert resp.status_code == 200
assert resp.json()['username'] == expected_username
@ -130,21 +127,18 @@ def test_login_same_email_different_provider(test_ldap_user, client, monkeypatch
"""
now_in_seconds = int(time.time())
id_token_expiration_timestamp = now_in_seconds + one_day_in_seconds
access_token_expiration_timestamp = now_in_seconds + one_hour_in_seconds
def userinfo_mock(*args, **kwargs):
return {'sub': 'email', 'email': test_ldap_user.email, 'exp': id_token_expiration_timestamp}
monkeypatch.setattr(AuthBackend, '_get_user_info', userinfo_mock)
# Confusingly the `ExpiresAt` header is expected to be in milliseconds.
# TODO: Change the frontend to pass seconds instead.
expires_at = (now_in_seconds + one_hour_in_seconds) * 1000
resp = client.get(
reverse('auth-login'),
HTTP_AUTHORIZATION='Bearer meh',
HTTP_IDTOKEN='meh',
HTTP_EXPIRESAT=str(expires_at)
HTTP_ACCESS_TOKEN_EXPIRES_AT=str(access_token_expiration_timestamp)
)
assert resp.status_code == 200
assert resp.json()['username'] == 'email/user@foo.com'
@ -155,21 +149,18 @@ def test_login_unknown_identity_provider(client, monkeypatch):
"""Test an id token `sub` value that does not match a known identity provider."""
now_in_seconds = int(time.time())
id_token_expiration_timestamp = now_in_seconds + one_day_in_seconds
access_token_expiration_timestamp = now_in_seconds + one_hour_in_seconds
def userinfo_mock(*args, **kwargs):
return {'sub': 'bad', 'email': 'foo@bar.com', 'exp': id_token_expiration_timestamp}
monkeypatch.setattr(AuthBackend, '_get_user_info', userinfo_mock)
# Confusingly the `ExpiresAt` header is expected to be in milliseconds.
# TODO: Change the frontend to pass seconds instead.
expires_at = (now_in_seconds + one_hour_in_seconds) * 1000
resp = client.get(
reverse("auth-login"),
HTTP_AUTHORIZATION="Bearer meh",
HTTP_IDTOKEN="meh",
HTTP_EXPIRESAT=str(expires_at)
HTTP_ACCESS_TOKEN_EXPIRES_AT=str(access_token_expiration_timestamp)
)
assert resp.status_code == 403
assert resp.json()["detail"] == "Unrecognized identity"
@ -180,16 +171,13 @@ def test_login_not_active(test_ldap_user, client, monkeypatch):
"""Test that login is not permitted if the user has been disabled."""
now_in_seconds = int(time.time())
id_token_expiration_timestamp = now_in_seconds + one_day_in_seconds
access_token_expiration_timestamp = now_in_seconds + one_hour_in_seconds
def userinfo_mock(*args, **kwargs):
return {'sub': 'Mozilla-LDAP', 'email': test_ldap_user.email, 'exp': id_token_expiration_timestamp}
monkeypatch.setattr(AuthBackend, '_get_user_info', userinfo_mock)
# Confusingly the `ExpiresAt` header is expected to be in milliseconds.
# TODO: Change the frontend to pass seconds instead.
expires_at = (now_in_seconds + one_hour_in_seconds) * 1000
test_ldap_user.is_active = False
test_ldap_user.save()
@ -197,7 +185,7 @@ def test_login_not_active(test_ldap_user, client, monkeypatch):
reverse("auth-login"),
HTTP_AUTHORIZATION="Bearer meh",
HTTP_IDTOKEN="meh",
HTTP_EXPIRESAT=str(expires_at)
HTTP_ACCESS_TOKEN_EXPIRES_AT=str(access_token_expiration_timestamp)
)
assert resp.status_code == 403
assert resp.json()["detail"] == "This user has been disabled."
@ -309,3 +297,110 @@ def test_login_id_token_invalid_signature(client):
)
assert resp.status_code == 403
assert resp.json()['detail'] == 'Invalid header: Unable to parse authentication'
def test_login_access_token_expiry_header_missing(client, monkeypatch):
now_in_seconds = int(time.time())
id_token_expiration_timestamp = now_in_seconds + one_day_in_seconds
def userinfo_mock(*args, **kwargs):
return {'sub': 'Mozilla-LDAP', 'email': 'x@y.z', 'exp': id_token_expiration_timestamp}
monkeypatch.setattr(AuthBackend, '_get_user_info', userinfo_mock)
resp = client.get(
reverse('auth-login'),
HTTP_AUTHORIZATION='Bearer foo',
HTTP_IDTOKEN='bar',
)
assert resp.status_code == 403
assert resp.json()['detail'] == 'Access-Token-Expires-At header is expected'
def test_login_access_token_expiry_header_malformed(client, monkeypatch):
now_in_seconds = int(time.time())
id_token_expiration_timestamp = now_in_seconds + one_day_in_seconds
def userinfo_mock(*args, **kwargs):
return {'sub': 'Mozilla-LDAP', 'email': 'x@y.z', 'exp': id_token_expiration_timestamp}
monkeypatch.setattr(AuthBackend, '_get_user_info', userinfo_mock)
resp = client.get(
reverse('auth-login'),
HTTP_AUTHORIZATION='Bearer foo',
HTTP_IDTOKEN='bar',
HTTP_ACCESS_TOKEN_EXPIRES_AT='aaa',
)
assert resp.status_code == 403
assert resp.json()['detail'] == 'Access-Token-Expires-At header value is invalid'
def test_login_access_token_expired(client, monkeypatch):
now_in_seconds = int(time.time())
id_token_expiration_timestamp = now_in_seconds + one_hour_in_seconds
access_token_expiration_timestamp = now_in_seconds - 30
def userinfo_mock(*args, **kwargs):
return {'sub': 'Mozilla-LDAP', 'email': 'x@y.z', 'exp': id_token_expiration_timestamp}
monkeypatch.setattr(AuthBackend, '_get_user_info', userinfo_mock)
resp = client.get(
reverse('auth-login'),
HTTP_AUTHORIZATION='Bearer foo',
HTTP_IDTOKEN='bar',
HTTP_ACCESS_TOKEN_EXPIRES_AT=str(access_token_expiration_timestamp),
)
assert resp.status_code == 403
assert resp.json()['detail'] == 'Session expiry time has already passed!'
def test_login_id_token_expires_before_access_token(test_ldap_user, client, monkeypatch):
"""
Test that the Django session expiration is set correctly if the Id Token expiration
is due to occur before the Access Token expires (normally it is the other way around).
"""
now_in_seconds = int(time.time())
id_token_expiration_timestamp = now_in_seconds + one_hour_in_seconds
access_token_expiration_timestamp = now_in_seconds + one_day_in_seconds
def userinfo_mock(*args, **kwargs):
return {'sub': 'email', 'email': test_ldap_user.email, 'exp': id_token_expiration_timestamp}
monkeypatch.setattr(AuthBackend, '_get_user_info', userinfo_mock)
resp = client.get(
reverse('auth-login'),
HTTP_AUTHORIZATION='Bearer meh',
HTTP_IDTOKEN='meh',
HTTP_ACCESS_TOKEN_EXPIRES_AT=str(access_token_expiration_timestamp)
)
assert resp.status_code == 200
assert client.session.get_expiry_age() == pytest.approx(one_hour_in_seconds, abs=5)
# TODO: Remove once enough time has passed for people to reload open UI tabs.
def test_login_legacy_headers(test_ldap_user, client, monkeypatch):
"""
Test that requests made using the `ExpiresAt` header still succeed.
"""
now_in_seconds = int(time.time())
id_token_expiration_timestamp = now_in_seconds + one_day_in_seconds
def userinfo_mock(*args, **kwargs):
return {'sub': 'Mozilla-LDAP', 'email': test_ldap_user.email, 'exp': id_token_expiration_timestamp}
monkeypatch.setattr(AuthBackend, '_get_user_info', userinfo_mock)
expires_at_in_milliseconds = (now_in_seconds + one_hour_in_seconds) * 1000
resp = client.get(
reverse('auth-login'),
HTTP_AUTHORIZATION='Bearer abc',
HTTP_IDTOKEN='abc',
HTTP_EXPIRESAT=str(expires_at_in_milliseconds)
)
assert resp.status_code == 200
assert resp.json()['username'] == test_ldap_user.username
assert client.session.get_expiry_age() == pytest.approx(one_hour_in_seconds, abs=5)

Просмотреть файл

@ -32,13 +32,21 @@ with open('treeherder/auth/jwks.json') as f:
class AuthBackend(object):
def _get_accesstoken_expiry(self, request):
expires_at_in_milliseconds = request.META.get("HTTP_EXPIRESAT")
def _get_access_token_expiry(self, request):
expiration_timestamp_in_seconds = request.META.get('HTTP_ACCESS_TOKEN_EXPIRES_AT')
if not expires_at_in_milliseconds:
raise AuthenticationFailed("expiresAt header is expected")
if not expiration_timestamp_in_seconds:
# Check for the previous name/type for this header.
# TODO: Remove once enough time has passed for people to reload open UI tabs.
expiration_timestamp_in_milliseconds = request.META.get('HTTP_EXPIRESAT')
if not expiration_timestamp_in_milliseconds:
raise AuthenticationFailed('Access-Token-Expires-At header is expected')
expiration_timestamp_in_seconds = int(expiration_timestamp_in_milliseconds) / 1000
return int(expires_at_in_milliseconds)
try:
return int(expiration_timestamp_in_seconds)
except ValueError:
raise AuthenticationFailed('Access-Token-Expires-At header value is invalid')
def _get_access_token(self, request):
auth = request.META.get('HTTP_AUTHORIZATION')
@ -166,20 +174,23 @@ class AuthBackend(object):
user_info = self._get_user_info(access_token, id_token)
username = self._get_username_from_userinfo(user_info)
accesstoken_exp_in_ms = self._get_accesstoken_expiry(request)
access_token_expiry_timestamp = self._get_access_token_expiry(request)
# Per http://openid.net/specs/openid-connect-core-1_0.html#IDToken, exp is given in seconds
idtoken_exp_in_ms = user_info['exp'] * 1000
now_in_ms = int(round(time.time() * 1000))
id_token_expiry_timestamp = user_info['exp']
now_in_seconds = int(time.time())
# The default Django user session length is overridden, since otherwise the access token
# in localstorage in the UI (used for Taskcluster interactions) might expire before the
# Django session does, resulting in confusing UX.
# The session length is set to match whichever token expiration time is closer.
session_expiry_in_ms = min(accesstoken_exp_in_ms, idtoken_exp_in_ms)
expires_in = int((session_expiry_in_ms - now_in_ms) / 1000)
earliest_expiration_timestamp = min(access_token_expiry_timestamp, id_token_expiry_timestamp)
seconds_until_expiry = earliest_expiration_timestamp - now_in_seconds
logger.debug('Updating session to expire in %i seconds', expires_in)
request.session.set_expiry(expires_in)
if seconds_until_expiry <= 0:
raise AuthError('Session expiry time has already passed!')
logger.debug('Updating session to expire in %i seconds', seconds_until_expiry)
request.session.set_expiry(seconds_until_expiry)
try:
return User.objects.get(username=username)

Просмотреть файл

@ -15,7 +15,43 @@ export const webAuth = new WebAuth({
});
export const userSessionFromAuthResult = authResult => {
const expiresAt = JSON.stringify(authResult.expiresIn * 1000 + Date.now());
// Example authResult:
// {
// "accessToken": "<TOKEN>",
// "idToken": "<TOKEN>",
// "idTokenPayload": {
// "https://sso.mozilla.com/claim/groups": [
// "all_scm_level_1",
// "all_scm_level_2",
// "all_scm_level_3"
// ],
// "given_name": "Firstname",
// "family_name": "Surname",
// "nickname": "Firstname Surname",
// "name": "Firstname Surname",
// "picture": "<GRAVATAR_URL>",
// "updated_at": "2019-02-13T17:26:19.538Z",
// "email": "fsurname@mozilla.com",
// "email_verified": true,
// "iss": "https://auth.mozilla.auth0.com/",
// "sub": "ad|Mozilla-LDAP|fsurname",
// "aud": "<HASH>",
// "iat": 1550078779,
// "exp": 1550683579,
// "at_hash": "<HASH>",
// "nonce": "<HASH>"
// },
// "appState": null,
// "refreshToken": null,
// "state": "<HASH>",
// "expiresIn": 86400,
// "tokenType": "Bearer",
// "scope": "openid profile email taskcluster-credentials"
// }
//
// For more details, see:
// https://auth0.com/docs/libraries/auth0js/v9#extract-the-authresult-and-get-user-info
//
const userSession = {
idToken: authResult.idToken,
accessToken: authResult.accessToken,
@ -23,8 +59,9 @@ export const userSessionFromAuthResult = authResult => {
picture: authResult.idTokenPayload.picture,
oidcSubject: authResult.idTokenPayload.sub,
url: authResult.url,
// expiresAt is used by the django backend to expire the user session
expiresAt,
// `accessTokenexpiresAt` is the unix timestamp (in seconds) at which the access token expires.
// It is used by the Django backend along with idToken's `exp` to determine session expiry.
accessTokenExpiresAt: authResult.expiresIn + Math.floor(Date.now() / 1000),
// per https://wiki.mozilla.org/Security/Guidelines/OpenID_connect#Session_handling
renewAfter: fromNow('15 minutes'),
};

Просмотреть файл

@ -18,9 +18,9 @@ export default class AuthService {
return new Promise(async (resolve, reject) => {
const userResponse = await fetch(loginUrl, {
headers: {
authorization: `Bearer ${userSession.accessToken}`,
Authorization: `Bearer ${userSession.accessToken}`,
'Access-Token-Expires-At': userSession.accessTokenExpiresAt,
idToken: userSession.idToken,
expiresAt: userSession.expiresAt,
},
method: 'GET',
credentials: 'same-origin',