providers/oauth2: add revoked field, create suspicious event when previous token is used
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
parent
3e60e956f4
commit
77a507d2f8
|
@ -3,6 +3,7 @@ from functools import partial
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import SuspiciousOperation
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
from django.db.models.signals import post_save, pre_delete
|
from django.db.models.signals import post_save, pre_delete
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
@ -63,7 +64,15 @@ class AuditMiddleware:
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
return
|
return
|
||||||
if before_send({}, {"exc_info": (None, exception, None)}) is not None:
|
# Special case for SuspiciousOperation, we have a special event action for that
|
||||||
|
if isinstance(exception, SuspiciousOperation):
|
||||||
|
thread = EventNewThread(
|
||||||
|
EventAction.SUSPICIOUS_REQUEST,
|
||||||
|
request,
|
||||||
|
message=str(exception),
|
||||||
|
)
|
||||||
|
thread.run()
|
||||||
|
elif before_send({}, {"exc_info": (None, exception, None)}) is not None:
|
||||||
thread = EventNewThread(
|
thread = EventNewThread(
|
||||||
EventAction.SYSTEM_EXCEPTION,
|
EventAction.SYSTEM_EXCEPTION,
|
||||||
request,
|
request,
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Generated by Django 3.2.4 on 2021-07-03 13:13
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_flows", "0021_flowstagebinding_invalid_response_action"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="flowstagebinding",
|
||||||
|
name="invalid_response_action",
|
||||||
|
field=models.TextField(
|
||||||
|
choices=[
|
||||||
|
("retry", "Retry"),
|
||||||
|
("restart", "Restart"),
|
||||||
|
("restart_with_context", "Restart With Context"),
|
||||||
|
],
|
||||||
|
default="retry",
|
||||||
|
help_text="Configure how the flow executor should handle an invalid response to a challenge. RETRY returns the error message and a similar challenge to the executor. RESTART restarts the flow from the beginning, and RESTART_WITH_CONTEXT restarts the flow while keeping the current context.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -51,6 +51,7 @@ class RefreshTokenModelSerializer(ExpiringBaseGrantModelSerializer):
|
||||||
"expires",
|
"expires",
|
||||||
"scope",
|
"scope",
|
||||||
"id_token",
|
"id_token",
|
||||||
|
"revoked",
|
||||||
]
|
]
|
||||||
depth = 2
|
depth = 2
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 3.2.4 on 2021-07-03 13:13
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_providers_oauth2", "0014_alter_oauth2provider_rsa_key"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="authorizationcode",
|
||||||
|
name="revoked",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="refreshtoken",
|
||||||
|
name="revoked",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
|
@ -318,6 +318,7 @@ class BaseGrantModel(models.Model):
|
||||||
provider = models.ForeignKey(OAuth2Provider, on_delete=models.CASCADE)
|
provider = models.ForeignKey(OAuth2Provider, on_delete=models.CASCADE)
|
||||||
user = models.ForeignKey(User, verbose_name=_("User"), on_delete=models.CASCADE)
|
user = models.ForeignKey(User, verbose_name=_("User"), on_delete=models.CASCADE)
|
||||||
_scope = models.TextField(default="", verbose_name=_("Scopes"))
|
_scope = models.TextField(default="", verbose_name=_("Scopes"))
|
||||||
|
revoked = models.BooleanField(default=False)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def scope(self) -> list[str]:
|
def scope(self) -> list[str]:
|
||||||
|
|
|
@ -6,6 +6,8 @@ from django.urls import reverse
|
||||||
from django.utils.encoding import force_str
|
from django.utils.encoding import force_str
|
||||||
|
|
||||||
from authentik.core.models import Application, User
|
from authentik.core.models import Application, User
|
||||||
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.flows.models import Flow
|
from authentik.flows.models import Flow
|
||||||
from authentik.providers.oauth2.constants import (
|
from authentik.providers.oauth2.constants import (
|
||||||
GRANT_TYPE_AUTHORIZATION_CODE,
|
GRANT_TYPE_AUTHORIZATION_CODE,
|
||||||
|
@ -39,7 +41,8 @@ class TestToken(OAuthTestCase):
|
||||||
client_id=generate_client_id(),
|
client_id=generate_client_id(),
|
||||||
client_secret=generate_client_secret(),
|
client_secret=generate_client_secret(),
|
||||||
authorization_flow=Flow.objects.first(),
|
authorization_flow=Flow.objects.first(),
|
||||||
redirect_uris="http://local.invalid",
|
redirect_uris="http://testserver",
|
||||||
|
rsa_key=CertificateKeyPair.objects.first(),
|
||||||
)
|
)
|
||||||
header = b64encode(
|
header = b64encode(
|
||||||
f"{provider.client_id}:{provider.client_secret}".encode()
|
f"{provider.client_id}:{provider.client_secret}".encode()
|
||||||
|
@ -53,11 +56,13 @@ class TestToken(OAuthTestCase):
|
||||||
data={
|
data={
|
||||||
"grant_type": GRANT_TYPE_AUTHORIZATION_CODE,
|
"grant_type": GRANT_TYPE_AUTHORIZATION_CODE,
|
||||||
"code": code.code,
|
"code": code.code,
|
||||||
"redirect_uri": "http://local.invalid",
|
"redirect_uri": "http://testserver",
|
||||||
},
|
},
|
||||||
HTTP_AUTHORIZATION=f"Basic {header}",
|
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||||
)
|
)
|
||||||
params = TokenParams.from_request(request)
|
params = TokenParams.parse(
|
||||||
|
request, provider, provider.client_id, provider.client_secret
|
||||||
|
)
|
||||||
self.assertEqual(params.provider, provider)
|
self.assertEqual(params.provider, provider)
|
||||||
|
|
||||||
def test_request_refresh_token(self):
|
def test_request_refresh_token(self):
|
||||||
|
@ -68,6 +73,7 @@ class TestToken(OAuthTestCase):
|
||||||
client_secret=generate_client_secret(),
|
client_secret=generate_client_secret(),
|
||||||
authorization_flow=Flow.objects.first(),
|
authorization_flow=Flow.objects.first(),
|
||||||
redirect_uris="http://local.invalid",
|
redirect_uris="http://local.invalid",
|
||||||
|
rsa_key=CertificateKeyPair.objects.first(),
|
||||||
)
|
)
|
||||||
header = b64encode(
|
header = b64encode(
|
||||||
f"{provider.client_id}:{provider.client_secret}".encode()
|
f"{provider.client_id}:{provider.client_secret}".encode()
|
||||||
|
@ -87,7 +93,9 @@ class TestToken(OAuthTestCase):
|
||||||
},
|
},
|
||||||
HTTP_AUTHORIZATION=f"Basic {header}",
|
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||||
)
|
)
|
||||||
params = TokenParams.from_request(request)
|
params = TokenParams.parse(
|
||||||
|
request, provider, provider.client_id, provider.client_secret
|
||||||
|
)
|
||||||
self.assertEqual(params.provider, provider)
|
self.assertEqual(params.provider, provider)
|
||||||
|
|
||||||
def test_auth_code_view(self):
|
def test_auth_code_view(self):
|
||||||
|
@ -98,6 +106,7 @@ class TestToken(OAuthTestCase):
|
||||||
client_secret=generate_client_secret(),
|
client_secret=generate_client_secret(),
|
||||||
authorization_flow=Flow.objects.first(),
|
authorization_flow=Flow.objects.first(),
|
||||||
redirect_uris="http://local.invalid",
|
redirect_uris="http://local.invalid",
|
||||||
|
rsa_key=CertificateKeyPair.objects.first(),
|
||||||
)
|
)
|
||||||
# Needs to be assigned to an application for iss to be set
|
# Needs to be assigned to an application for iss to be set
|
||||||
self.app.provider = provider
|
self.app.provider = provider
|
||||||
|
@ -141,6 +150,7 @@ class TestToken(OAuthTestCase):
|
||||||
client_secret=generate_client_secret(),
|
client_secret=generate_client_secret(),
|
||||||
authorization_flow=Flow.objects.first(),
|
authorization_flow=Flow.objects.first(),
|
||||||
redirect_uris="http://local.invalid",
|
redirect_uris="http://local.invalid",
|
||||||
|
rsa_key=CertificateKeyPair.objects.first(),
|
||||||
)
|
)
|
||||||
# Needs to be assigned to an application for iss to be set
|
# Needs to be assigned to an application for iss to be set
|
||||||
self.app.provider = provider
|
self.app.provider = provider
|
||||||
|
@ -193,6 +203,7 @@ class TestToken(OAuthTestCase):
|
||||||
client_secret=generate_client_secret(),
|
client_secret=generate_client_secret(),
|
||||||
authorization_flow=Flow.objects.first(),
|
authorization_flow=Flow.objects.first(),
|
||||||
redirect_uris="http://local.invalid",
|
redirect_uris="http://local.invalid",
|
||||||
|
rsa_key=CertificateKeyPair.objects.first(),
|
||||||
)
|
)
|
||||||
header = b64encode(
|
header = b64encode(
|
||||||
f"{provider.client_id}:{provider.client_secret}".encode()
|
f"{provider.client_id}:{provider.client_secret}".encode()
|
||||||
|
@ -230,3 +241,65 @@ class TestToken(OAuthTestCase):
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_refresh_token_revoke(self):
|
||||||
|
"""test request param"""
|
||||||
|
provider = OAuth2Provider.objects.create(
|
||||||
|
name="test",
|
||||||
|
client_id=generate_client_id(),
|
||||||
|
client_secret=generate_client_secret(),
|
||||||
|
authorization_flow=Flow.objects.first(),
|
||||||
|
redirect_uris="http://testserver",
|
||||||
|
rsa_key=CertificateKeyPair.objects.first(),
|
||||||
|
)
|
||||||
|
# Needs to be assigned to an application for iss to be set
|
||||||
|
self.app.provider = provider
|
||||||
|
self.app.save()
|
||||||
|
header = b64encode(
|
||||||
|
f"{provider.client_id}:{provider.client_secret}".encode()
|
||||||
|
).decode()
|
||||||
|
user = User.objects.get(username="akadmin")
|
||||||
|
token: RefreshToken = RefreshToken.objects.create(
|
||||||
|
provider=provider,
|
||||||
|
user=user,
|
||||||
|
refresh_token=generate_client_id(),
|
||||||
|
)
|
||||||
|
# Create initial refresh token
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("authentik_providers_oauth2:token"),
|
||||||
|
data={
|
||||||
|
"grant_type": GRANT_TYPE_REFRESH_TOKEN,
|
||||||
|
"refresh_token": token.refresh_token,
|
||||||
|
"redirect_uri": "http://testserver",
|
||||||
|
},
|
||||||
|
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||||
|
)
|
||||||
|
new_token: RefreshToken = (
|
||||||
|
RefreshToken.objects.filter(user=user).exclude(pk=token.pk).first()
|
||||||
|
)
|
||||||
|
# Post again with initial token -> get new refresh token
|
||||||
|
# and revoke old one
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("authentik_providers_oauth2:token"),
|
||||||
|
data={
|
||||||
|
"grant_type": GRANT_TYPE_REFRESH_TOKEN,
|
||||||
|
"refresh_token": new_token.refresh_token,
|
||||||
|
"redirect_uri": "http://local.invalid",
|
||||||
|
},
|
||||||
|
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
# Post again with old token, is now revoked and should error
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("authentik_providers_oauth2:token"),
|
||||||
|
data={
|
||||||
|
"grant_type": GRANT_TYPE_REFRESH_TOKEN,
|
||||||
|
"refresh_token": new_token.refresh_token,
|
||||||
|
"redirect_uri": "http://local.invalid",
|
||||||
|
},
|
||||||
|
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertTrue(
|
||||||
|
Event.objects.filter(action=EventAction.SUSPICIOUS_REQUEST).exists()
|
||||||
|
)
|
||||||
|
|
|
@ -10,6 +10,7 @@ from django.http.response import HttpResponseRedirect
|
||||||
from django.utils.cache import patch_vary_headers
|
from django.utils.cache import patch_vary_headers
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.providers.oauth2.errors import BearerTokenError
|
from authentik.providers.oauth2.errors import BearerTokenError
|
||||||
from authentik.providers.oauth2.models import RefreshToken
|
from authentik.providers.oauth2.models import RefreshToken
|
||||||
|
|
||||||
|
@ -132,22 +133,31 @@ def protected_resource_view(scopes: list[str]):
|
||||||
raise BearerTokenError("invalid_token")
|
raise BearerTokenError("invalid_token")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
kwargs["token"] = RefreshToken.objects.get(
|
token: RefreshToken = RefreshToken.objects.get(
|
||||||
access_token=access_token
|
access_token=access_token
|
||||||
)
|
)
|
||||||
except RefreshToken.DoesNotExist:
|
except RefreshToken.DoesNotExist:
|
||||||
LOGGER.debug("Token does not exist", access_token=access_token)
|
LOGGER.debug("Token does not exist", access_token=access_token)
|
||||||
raise BearerTokenError("invalid_token")
|
raise BearerTokenError("invalid_token")
|
||||||
|
|
||||||
if kwargs["token"].is_expired:
|
if token.is_expired:
|
||||||
LOGGER.debug("Token has expired", access_token=access_token)
|
LOGGER.debug("Token has expired", access_token=access_token)
|
||||||
raise BearerTokenError("invalid_token")
|
raise BearerTokenError("invalid_token")
|
||||||
|
|
||||||
if not set(scopes).issubset(set(kwargs["token"].scope)):
|
if token.revoked:
|
||||||
|
LOGGER.warning("Revoked token was used", access_token=access_token)
|
||||||
|
Event.new(
|
||||||
|
action=EventAction.SUSPICIOUS_REQUEST,
|
||||||
|
message="Revoked refresh token was used",
|
||||||
|
token=access_token,
|
||||||
|
).from_http(request)
|
||||||
|
raise BearerTokenError("invalid_token")
|
||||||
|
|
||||||
|
if not set(scopes).issubset(set(token.scope)):
|
||||||
LOGGER.warning(
|
LOGGER.warning(
|
||||||
"Scope missmatch.",
|
"Scope missmatch.",
|
||||||
required=set(scopes),
|
required=set(scopes),
|
||||||
token_has=set(kwargs["token"].scope),
|
token_has=set(token.scope),
|
||||||
)
|
)
|
||||||
raise BearerTokenError("insufficient_scope")
|
raise BearerTokenError("insufficient_scope")
|
||||||
except BearerTokenError as error:
|
except BearerTokenError as error:
|
||||||
|
@ -156,7 +166,7 @@ def protected_resource_view(scopes: list[str]):
|
||||||
"WWW-Authenticate"
|
"WWW-Authenticate"
|
||||||
] = f'error="{error.code}", error_description="{error.description}"'
|
] = f'error="{error.code}", error_description="{error.description}"'
|
||||||
return response
|
return response
|
||||||
|
kwargs["token"] = token
|
||||||
return view(request, *args, **kwargs)
|
return view(request, *args, **kwargs)
|
||||||
|
|
||||||
return view_wrapper
|
return view_wrapper
|
||||||
|
|
|
@ -8,6 +8,7 @@ from django.http import HttpRequest, HttpResponse
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.lib.utils.time import timedelta_from_string
|
from authentik.lib.utils.time import timedelta_from_string
|
||||||
from authentik.providers.oauth2.constants import (
|
from authentik.providers.oauth2.constants import (
|
||||||
GRANT_TYPE_AUTHORIZATION_CODE,
|
GRANT_TYPE_AUTHORIZATION_CODE,
|
||||||
|
@ -59,6 +60,7 @@ class TokenParams:
|
||||||
client_id: str,
|
client_id: str,
|
||||||
client_secret: str,
|
client_secret: str,
|
||||||
) -> "TokenParams":
|
) -> "TokenParams":
|
||||||
|
"""Parse params for request"""
|
||||||
return TokenParams(
|
return TokenParams(
|
||||||
# Init vars
|
# Init vars
|
||||||
raw_code=request.POST.get("code", ""),
|
raw_code=request.POST.get("code", ""),
|
||||||
|
@ -107,7 +109,14 @@ class TokenParams:
|
||||||
token=raw_token,
|
token=raw_token,
|
||||||
)
|
)
|
||||||
raise TokenError("invalid_grant")
|
raise TokenError("invalid_grant")
|
||||||
|
if self.refresh_token.revoked:
|
||||||
|
LOGGER.warning("Refresh token is revoked", token=raw_token)
|
||||||
|
Event.new(
|
||||||
|
action=EventAction.SUSPICIOUS_REQUEST,
|
||||||
|
message="Revoked refresh token was used",
|
||||||
|
token=raw_token,
|
||||||
|
).from_http(request)
|
||||||
|
raise TokenError("invalid_grant")
|
||||||
else:
|
else:
|
||||||
LOGGER.warning("Invalid grant type", grant_type=self.grant_type)
|
LOGGER.warning("Invalid grant type", grant_type=self.grant_type)
|
||||||
raise TokenError("unsupported_grant_type")
|
raise TokenError("unsupported_grant_type")
|
||||||
|
@ -178,15 +187,15 @@ class TokenView(View):
|
||||||
try:
|
try:
|
||||||
client_id, client_secret = extract_client_auth(request)
|
client_id, client_secret = extract_client_auth(request)
|
||||||
try:
|
try:
|
||||||
self.provider: OAuth2Provider = OAuth2Provider.objects.get(
|
self.provider = OAuth2Provider.objects.get(client_id=client_id)
|
||||||
client_id=client_id
|
|
||||||
)
|
|
||||||
except OAuth2Provider.DoesNotExist:
|
except OAuth2Provider.DoesNotExist:
|
||||||
LOGGER.warning(
|
LOGGER.warning(
|
||||||
"OAuth2Provider does not exist", client_id=self.client_id
|
"OAuth2Provider does not exist", client_id=self.client_id
|
||||||
)
|
)
|
||||||
raise TokenError("invalid_client")
|
raise TokenError("invalid_client")
|
||||||
|
|
||||||
|
if not self.provider:
|
||||||
|
raise ValueError
|
||||||
self.params = TokenParams.parse(
|
self.params = TokenParams.parse(
|
||||||
request, self.provider, client_id, client_secret
|
request, self.provider, client_id, client_secret
|
||||||
)
|
)
|
||||||
|
@ -265,8 +274,9 @@ class TokenView(View):
|
||||||
# Store the refresh_token.
|
# Store the refresh_token.
|
||||||
refresh_token.save()
|
refresh_token.save()
|
||||||
|
|
||||||
# Forget the old token.
|
# Mark old token as revoked
|
||||||
self.params.refresh_token.delete()
|
self.params.refresh_token.revoked = True
|
||||||
|
self.params.refresh_token.save()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"access_token": refresh_token.access_token,
|
"access_token": refresh_token.access_token,
|
||||||
|
|
Reference in New Issue