Skip to content

Commit 27f45dc

Browse files
committed
Improve plugin authentication (#1995)
# What this PR does Handle different failing authentication scenarios (e.g. when token is invalid or instance context is not a valid JSON) so endpoints return appropriate response code (401 instead of 500). ## Which issue(s) this PR fixes Related to grafana/oncall-private#1633 ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required)
1 parent ac9b82f commit 27f45dc

File tree

5 files changed

+106
-6
lines changed

5 files changed

+106
-6
lines changed

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## Unreleased
9+
10+
### Fixed
11+
12+
- Improve plugin authentication by @vadimkerr ([#1995](https://github.com/grafana/oncall/pull/1995))
13+
814
## v1.2.27 (2023-05-23)
915

1016
### Added

engine/apps/auth_token/auth.py

+17-2
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,14 @@ def authenticate_credentials(self, token_string: str, request: Request) -> Tuple
7272
if not context_string:
7373
raise exceptions.AuthenticationFailed("No instance context provided.")
7474

75-
context = json.loads(context_string)
75+
try:
76+
context = dict(json.loads(context_string))
77+
except (ValueError, TypeError):
78+
raise exceptions.AuthenticationFailed("Instance context must be JSON dict.")
79+
80+
if "stack_id" not in context or "org_id" not in context:
81+
raise exceptions.AuthenticationFailed("Invalid instance context.")
82+
7683
try:
7784
auth_token = check_token(token_string, context=context)
7885
if not auth_token.organization:
@@ -85,11 +92,19 @@ def authenticate_credentials(self, token_string: str, request: Request) -> Tuple
8592

8693
@staticmethod
8794
def _get_user(request: Request, organization: Organization) -> User:
88-
context = json.loads(request.headers.get("X-Grafana-Context"))
95+
try:
96+
context = dict(json.loads(request.headers.get("X-Grafana-Context")))
97+
except (ValueError, TypeError):
98+
raise exceptions.AuthenticationFailed("Grafana context must be JSON dict.")
99+
100+
if "UserId" not in context and "UserID" not in context:
101+
raise exceptions.AuthenticationFailed("Invalid Grafana context.")
102+
89103
try:
90104
user_id = context["UserId"]
91105
except KeyError:
92106
user_id = context["UserID"]
107+
93108
try:
94109
return organization.users.get(user_id=user_id)
95110
except User.DoesNotExist:

engine/apps/auth_token/models/plugin_auth_token.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import binascii
22
from hmac import compare_digest
3-
from typing import Optional, Tuple
3+
from typing import Tuple
44

55
from django.db import models
66

@@ -38,7 +38,7 @@ def create_auth_token(cls, organization: Organization) -> Tuple["PluginAuthToken
3838
return auth_token, token_string
3939

4040
@classmethod
41-
def validate_token_string(cls, token: str, *args, **kwargs) -> Optional["PluginAuthToken"]:
41+
def validate_token_string(cls, token: str, *args, **kwargs) -> "PluginAuthToken":
4242
context = kwargs["context"]
4343
for auth_token in cls.objects.filter(token_key=token[: constants.TOKEN_KEY_LENGTH]):
4444
try:
@@ -51,3 +51,5 @@ def validate_token_string(cls, token: str, *args, **kwargs) -> Optional["PluginA
5151
raise InvalidToken
5252
if compare_digest(digest, auth_token.digest) and token == recreated_token:
5353
return auth_token
54+
55+
raise InvalidToken
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import pytest
2+
from django.utils import timezone
3+
from rest_framework.exceptions import AuthenticationFailed
4+
from rest_framework.test import APIRequestFactory
5+
6+
from apps.auth_token.auth import PluginAuthentication
7+
8+
9+
@pytest.mark.django_db
10+
def test_plugin_authentication_self_hosted_success(make_organization, make_user, make_token_for_organization):
11+
organization = make_organization(stack_id=42, org_id=24)
12+
user = make_user(organization=organization, user_id=12)
13+
token, token_string = make_token_for_organization(organization)
14+
15+
headers = {
16+
"HTTP_AUTHORIZATION": token_string,
17+
"HTTP_X-Instance-Context": '{"stack_id": 42, "org_id": 24}',
18+
"HTTP_X-Grafana-Context": '{"UserId": 12}',
19+
}
20+
request = APIRequestFactory().get("/", **headers)
21+
22+
assert PluginAuthentication().authenticate(request) == (user, token)
23+
24+
25+
@pytest.mark.django_db
26+
def test_plugin_authentication_gcom_success(make_organization, make_user, make_token_for_organization):
27+
# Setting gcom_token_org_last_time_synced to now, so it doesn't try to sync with gcom
28+
organization = make_organization(
29+
stack_id=42, org_id=24, gcom_token="123", gcom_token_org_last_time_synced=timezone.now()
30+
)
31+
user = make_user(organization=organization, user_id=12)
32+
33+
headers = {
34+
"HTTP_AUTHORIZATION": "gcom:123",
35+
"HTTP_X-Instance-Context": '{"stack_id": 42, "org_id": 24}',
36+
"HTTP_X-Grafana-Context": '{"UserId": 12}',
37+
}
38+
request = APIRequestFactory().get("/", **headers)
39+
40+
ret_user, ret_token = PluginAuthentication().authenticate(request)
41+
assert ret_user == user
42+
assert ret_token.organization == organization
43+
44+
45+
@pytest.mark.django_db
46+
@pytest.mark.parametrize("grafana_context", [None, "", "non-json", '"string"', "{}", '{"UserId": 1}'])
47+
def test_plugin_authentication_fail_grafana_context(
48+
make_organization, make_user, make_token_for_organization, grafana_context
49+
):
50+
organization = make_organization(stack_id=42, org_id=24)
51+
token, token_string = make_token_for_organization(organization)
52+
53+
headers = {"HTTP_AUTHORIZATION": token_string, "HTTP_X-Instance-Context": '{"stack_id": 42, "org_id": 24}'}
54+
if grafana_context is not None:
55+
headers["HTTP_X-Grafana-Context"] = grafana_context
56+
57+
request = APIRequestFactory().get("/", **headers)
58+
with pytest.raises(AuthenticationFailed):
59+
PluginAuthentication().authenticate(request)
60+
61+
62+
@pytest.mark.django_db
63+
@pytest.mark.parametrize("authorization", [None, "", "123", "gcom:123"])
64+
@pytest.mark.parametrize("instance_context", [None, "", "non-json", '"string"', "{}", '{"stack_id": 1, "org_id": 1}'])
65+
def test_plugin_authentication_fail(authorization, instance_context):
66+
headers = {}
67+
68+
if authorization is not None:
69+
headers["HTTP_AUTHORIZATION"] = authorization
70+
71+
if instance_context is not None:
72+
headers["HTTP_X-Instance-Context"] = instance_context
73+
74+
request = APIRequestFactory().get("/", **headers)
75+
76+
with pytest.raises(AuthenticationFailed):
77+
PluginAuthentication().authenticate(request)

engine/apps/grafana_plugin/helpers/gcom.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def __init__(self, organization):
2020
self.organization = organization
2121

2222

23-
def check_gcom_permission(token_string: str, context) -> Optional["GcomToken"]:
23+
def check_gcom_permission(token_string: str, context) -> GcomToken:
2424
"""
2525
Verify that request from plugin is valid. Check it and synchronize the organization details
2626
with gcom every GCOM_TOKEN_CHECK_PERIOD.
@@ -87,7 +87,7 @@ def check_gcom_permission(token_string: str, context) -> Optional["GcomToken"]:
8787
return GcomToken(organization)
8888

8989

90-
def check_token(token_string: str, context: dict):
90+
def check_token(token_string: str, context: dict) -> GcomToken | PluginAuthToken:
9191
token_parts = token_string.split(":")
9292
if len(token_parts) > 1 and token_parts[0] == "gcom":
9393
return check_gcom_permission(token_parts[1], context)

0 commit comments

Comments
 (0)