Add support for revoking refresh tokens (#16095)

* Add support for revoking refresh tokens

* Lint

* Split revoke logic in own method

* Simplify

* Update docs
This commit is contained in:
Paulus Schoutsen 2018-08-21 20:02:55 +02:00 committed by Jason Hu
parent 00c6f56cc8
commit cdb8361050
5 changed files with 122 additions and 5 deletions

View File

@ -202,6 +202,12 @@ class AuthManager:
"""Get refresh token by token."""
return await self._store.async_get_refresh_token_by_token(token)
async def async_remove_refresh_token(self,
refresh_token: models.RefreshToken) \
-> None:
"""Delete a refresh token."""
await self._store.async_remove_refresh_token(refresh_token)
@callback
def async_create_access_token(self,
refresh_token: models.RefreshToken) -> str:
@ -215,7 +221,7 @@ class AuthManager:
async def async_validate_access_token(
self, token: str) -> Optional[models.RefreshToken]:
"""Return if an access token is valid."""
"""Return refresh token if an access token is valid."""
try:
unverif_claims = jwt.decode(token, verify=False)
except jwt.InvalidTokenError:

View File

@ -136,6 +136,18 @@ class AuthStore:
self._async_schedule_save()
return refresh_token
async def async_remove_refresh_token(
self, refresh_token: models.RefreshToken) -> None:
"""Remove a refresh token."""
if self._users is None:
await self._async_load()
assert self._users is not None
for user in self._users.values():
if user.refresh_tokens.pop(refresh_token.id, None):
self._async_schedule_save()
break
async def async_get_refresh_token(
self, token_id: str) -> Optional[models.RefreshToken]:
"""Get refresh token by id."""

View File

@ -44,11 +44,23 @@ a limited expiration.
"expires_in": 1800,
"token_type": "Bearer"
}
## Revoking a refresh token
It is also possible to revoke a refresh token and all access tokens that have
ever been granted by that refresh token. Response code will ALWAYS be 200.
{
"token": "IJKLMNOPQRST",
"action": "revoke"
}
"""
import logging
import uuid
from datetime import timedelta
from aiohttp import web
import voluptuous as vol
from homeassistant.auth.models import User, Credentials
@ -79,7 +91,7 @@ async def async_setup(hass, config):
"""Component to allow users to login."""
store_result, retrieve_result = _create_auth_code_store()
hass.http.register_view(GrantTokenView(retrieve_result))
hass.http.register_view(TokenView(retrieve_result))
hass.http.register_view(LinkUserView(retrieve_result))
hass.components.websocket_api.async_register_command(
@ -92,8 +104,8 @@ async def async_setup(hass, config):
return True
class GrantTokenView(HomeAssistantView):
"""View to grant tokens."""
class TokenView(HomeAssistantView):
"""View to issue or revoke tokens."""
url = '/auth/token'
name = 'api:auth:token'
@ -101,7 +113,7 @@ class GrantTokenView(HomeAssistantView):
cors_allowed = True
def __init__(self, retrieve_user):
"""Initialize the grant token view."""
"""Initialize the token view."""
self._retrieve_user = retrieve_user
@log_invalid_auth
@ -112,6 +124,13 @@ class GrantTokenView(HomeAssistantView):
grant_type = data.get('grant_type')
# IndieAuth 6.3.5
# The revocation endpoint is the same as the token endpoint.
# The revocation request includes an additional parameter,
# action=revoke.
if data.get('action') == 'revoke':
return await self._async_handle_revoke_token(hass, data)
if grant_type == 'authorization_code':
return await self._async_handle_auth_code(hass, data)
@ -122,6 +141,25 @@ class GrantTokenView(HomeAssistantView):
'error': 'unsupported_grant_type',
}, status_code=400)
async def _async_handle_revoke_token(self, hass, data):
"""Handle revoke token request."""
# OAuth 2.0 Token Revocation [RFC7009]
# 2.2 The authorization server responds with HTTP status code 200
# if the token has been revoked successfully or if the client
# submitted an invalid token.
token = data.get('token')
if token is None:
return web.Response(status=200)
refresh_token = await hass.auth.async_get_refresh_token_by_token(token)
if refresh_token is None:
return web.Response(status=200)
await hass.auth.async_remove_refresh_token(refresh_token)
return web.Response(status=200)
async def _async_handle_auth_code(self, hass, data):
"""Handle authorization code request."""
client_id = data.get('client_id')
@ -136,6 +174,7 @@ class GrantTokenView(HomeAssistantView):
if code is None:
return self.json({
'error': 'invalid_request',
'error_description': 'Invalid code',
}, status_code=400)
user = self._retrieve_user(client_id, RESULT_TYPE_USER, code)

View File

@ -281,3 +281,20 @@ async def test_cannot_deactive_owner(mock_hass):
with pytest.raises(ValueError):
await manager.async_deactivate_user(owner)
async def test_remove_refresh_token(mock_hass):
"""Test that we can remove a refresh token."""
manager = await auth.auth_manager_from_config(mock_hass, [])
user = MockUser().add_to_auth_manager(manager)
refresh_token = await manager.async_create_refresh_token(user, CLIENT_ID)
access_token = manager.async_create_access_token(refresh_token)
await manager.async_remove_refresh_token(refresh_token)
assert (
await manager.async_get_refresh_token(refresh_token.id) is None
)
assert (
await manager.async_validate_access_token(access_token) is None
)

View File

@ -224,3 +224,46 @@ async def test_refresh_token_different_client_id(hass, aiohttp_client):
await hass.auth.async_validate_access_token(tokens['access_token'])
is not None
)
async def test_revoking_refresh_token(hass, aiohttp_client):
"""Test that we can revoke refresh tokens."""
client = await async_setup_auth(hass, aiohttp_client)
user = await hass.auth.async_create_user('Test User')
refresh_token = await hass.auth.async_create_refresh_token(user, CLIENT_ID)
# Test that we can create an access token
resp = await client.post('/auth/token', data={
'client_id': CLIENT_ID,
'grant_type': 'refresh_token',
'refresh_token': refresh_token.token,
})
assert resp.status == 200
tokens = await resp.json()
assert (
await hass.auth.async_validate_access_token(tokens['access_token'])
is not None
)
# Revoke refresh token
resp = await client.post('/auth/token', data={
'token': refresh_token.token,
'action': 'revoke',
})
assert resp.status == 200
# Old access token should be no longer valid
assert (
await hass.auth.async_validate_access_token(tokens['access_token'])
is None
)
# Test that we no longer can create an access token
resp = await client.post('/auth/token', data={
'client_id': CLIENT_ID,
'grant_type': 'refresh_token',
'refresh_token': refresh_token.token,
})
assert resp.status == 400