mirror of
https://github.com/home-assistant/core.git
synced 2025-04-25 01:38:02 +00:00
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:
parent
00c6f56cc8
commit
cdb8361050
@ -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:
|
||||
|
@ -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."""
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user