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.""" """Get refresh token by token."""
return await self._store.async_get_refresh_token_by_token(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 @callback
def async_create_access_token(self, def async_create_access_token(self,
refresh_token: models.RefreshToken) -> str: refresh_token: models.RefreshToken) -> str:
@ -215,7 +221,7 @@ class AuthManager:
async def async_validate_access_token( async def async_validate_access_token(
self, token: str) -> Optional[models.RefreshToken]: self, token: str) -> Optional[models.RefreshToken]:
"""Return if an access token is valid.""" """Return refresh token if an access token is valid."""
try: try:
unverif_claims = jwt.decode(token, verify=False) unverif_claims = jwt.decode(token, verify=False)
except jwt.InvalidTokenError: except jwt.InvalidTokenError:

View File

@ -136,6 +136,18 @@ class AuthStore:
self._async_schedule_save() self._async_schedule_save()
return refresh_token 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( async def async_get_refresh_token(
self, token_id: str) -> Optional[models.RefreshToken]: self, token_id: str) -> Optional[models.RefreshToken]:
"""Get refresh token by id.""" """Get refresh token by id."""

View File

@ -44,11 +44,23 @@ a limited expiration.
"expires_in": 1800, "expires_in": 1800,
"token_type": "Bearer" "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 logging
import uuid import uuid
from datetime import timedelta from datetime import timedelta
from aiohttp import web
import voluptuous as vol import voluptuous as vol
from homeassistant.auth.models import User, Credentials from homeassistant.auth.models import User, Credentials
@ -79,7 +91,7 @@ async def async_setup(hass, config):
"""Component to allow users to login.""" """Component to allow users to login."""
store_result, retrieve_result = _create_auth_code_store() 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.http.register_view(LinkUserView(retrieve_result))
hass.components.websocket_api.async_register_command( hass.components.websocket_api.async_register_command(
@ -92,8 +104,8 @@ async def async_setup(hass, config):
return True return True
class GrantTokenView(HomeAssistantView): class TokenView(HomeAssistantView):
"""View to grant tokens.""" """View to issue or revoke tokens."""
url = '/auth/token' url = '/auth/token'
name = 'api:auth:token' name = 'api:auth:token'
@ -101,7 +113,7 @@ class GrantTokenView(HomeAssistantView):
cors_allowed = True cors_allowed = True
def __init__(self, retrieve_user): def __init__(self, retrieve_user):
"""Initialize the grant token view.""" """Initialize the token view."""
self._retrieve_user = retrieve_user self._retrieve_user = retrieve_user
@log_invalid_auth @log_invalid_auth
@ -112,6 +124,13 @@ class GrantTokenView(HomeAssistantView):
grant_type = data.get('grant_type') 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': if grant_type == 'authorization_code':
return await self._async_handle_auth_code(hass, data) return await self._async_handle_auth_code(hass, data)
@ -122,6 +141,25 @@ class GrantTokenView(HomeAssistantView):
'error': 'unsupported_grant_type', 'error': 'unsupported_grant_type',
}, status_code=400) }, 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): async def _async_handle_auth_code(self, hass, data):
"""Handle authorization code request.""" """Handle authorization code request."""
client_id = data.get('client_id') client_id = data.get('client_id')
@ -136,6 +174,7 @@ class GrantTokenView(HomeAssistantView):
if code is None: if code is None:
return self.json({ return self.json({
'error': 'invalid_request', 'error': 'invalid_request',
'error_description': 'Invalid code',
}, status_code=400) }, status_code=400)
user = self._retrieve_user(client_id, RESULT_TYPE_USER, code) 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): with pytest.raises(ValueError):
await manager.async_deactivate_user(owner) 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']) await hass.auth.async_validate_access_token(tokens['access_token'])
is not None 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