mirror of
https://github.com/home-assistant/core.git
synced 2025-04-29 03:37:51 +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."""
|
"""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:
|
||||||
|
@ -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."""
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
)
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user