Skip to content

Commit 5f85066

Browse files
Brf/feat/add oauth2 provider support (#82)
* Add OAUTH2 provider support and featuresrv proxy support * Bump arches core requirement * Make outbound proxy configurable in settings and eliminate duplicate proxy class * Don't log user out if they have a valid BCAP-provided OAuth to token. * Add test to bump coverage --------- Co-authored-by: Aaron Gundel <124614+aarongundel@users.noreply.github.com>
1 parent d34529d commit 5f85066

File tree

4 files changed

+179
-28
lines changed

4 files changed

+179
-28
lines changed

bcgov_arches_common/util/auth/oauth_token_refresh.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from django.conf import settings
66
from bcgov_arches_common.util.auth.token_store import save_token
77
from bcgov_arches_common.util.auth.oauth_session_control import log_user_out
8+
from oauth2_provider.oauth2_backends import get_oauthlib_core
89

910
logger = logging.getLogger(__name__)
1011

@@ -38,10 +39,37 @@ def __init__(self, get_response):
3839
self.get_response = get_response
3940
self.oauth_config = OAUTH_CONFIG
4041

42+
@staticmethod
43+
def dot_access_token_is_valid(request) -> bool:
44+
"""
45+
Returns True if request includes a valid django-oauth-toolkit Bearer token.
46+
Does not raise; safe to call from middleware.
47+
"""
48+
auth = request.headers.get("Authorization", "")
49+
if not auth.lower().startswith("bearer "):
50+
return False
51+
52+
# oauthlib core will validate signature/expiry/scope rules etc.
53+
core = get_oauthlib_core()
54+
valid, oauth2_req = core.verify_request(request, scopes=[])
55+
56+
if valid:
57+
# Optional: make token/user info available downstream
58+
request.oauth2_validated = True
59+
request.access_token = getattr(oauth2_req, "access_token", None)
60+
request.resource_owner = getattr(oauth2_req, "user", None)
61+
print("User has a valid DOT access token")
62+
return True
63+
64+
return False
65+
4166
def __call__(self, request):
4267
if bypass_auth(request):
4368
return self.get_response(request)
4469

70+
# If request has a valid DOT bearer token, do NOT redirect to HOME_PAGE
71+
has_valid_dot_token = self.dot_access_token_is_valid(request)
72+
4573
if logger.isEnabledFor(logging.DEBUG):
4674
expiry_timestamp = request.session.get_expiry_date().timestamp()
4775
now = time.time()
@@ -84,11 +112,15 @@ def __call__(self, request):
84112
log_user_out(request)
85113
return redirect(UNAUTHORIZED_PAGE)
86114
else:
87-
if request.user.is_authenticated:
115+
if request.user.is_authenticated and not has_valid_dot_token:
88116
logger.warning(f"[Token] No token - logging user out.")
89117
log_user_out(request)
90118

91-
if AUTH_REQUIRED and not request.user.is_authenticated:
119+
if (
120+
AUTH_REQUIRED
121+
and not request.user.is_authenticated
122+
and not has_valid_dot_token
123+
):
92124
return redirect(HOME_PAGE)
93125

94126
return self.get_response(request)

bcgov_arches_common/views/map.py

Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,60 @@
1-
import urllib3
21
from arches.app.views.map import TileserverProxyView
32
from django.conf import settings
3+
from bcgov_arches_common.views.outbound_proxy_mixin import OutboundProxyMixin
44

55

6-
class BCTileserverLocalProxyView(TileserverProxyView):
7-
upstream_urls = settings.BC_TILESERVER_URLS
6+
class BCTileserverProxyView(TileserverProxyView, OutboundProxyMixin):
87
"""
98
Subclass of the TileserverProxyView that has multiple upstream servers.
10-
- the BC_TILESERVER_URLS is a dict with source->upstream URL mapping
11-
- the request URL must have the source parameter set. If not set it defaults to the parent upstream value
9+
- the BC_TILESERVER_URLS is a dict with source->upstream URL mapping and outbound proxy configuration
10+
- the request URL must have the source parameter set. If not set it defaults to "openmaps"
1211
"""
1312

14-
def __init__(self, *args, **kwargs):
15-
super(BCTileserverLocalProxyView, self).__init__(*args, **kwargs)
13+
DEFAULT_SOURCE = "openmaps"
14+
DEFAULT_CONFIG = {
15+
"url": getattr(settings, "TILESERVER_URL", "https://openmaps.gov.bc.ca/"),
16+
"use_outbound_proxy": False,
17+
}
18+
19+
@property
20+
def upstream_urls(self):
21+
"""
22+
Get upstream URLs from settings, ensuring the default source is always available.
23+
"""
24+
# Get configuration from settings or empty dict if not defined
25+
urls = getattr(settings, "BC_TILESERVER_URLS", {})
26+
27+
# Ensure default source exists
28+
if self.DEFAULT_SOURCE not in urls:
29+
urls[self.DEFAULT_SOURCE] = self.DEFAULT_CONFIG
30+
31+
return urls
1632

1733
def get_request_headers(self):
1834
proxy_source = self.request.GET.get("source", "")
19-
# print("\tProxy source: %s" % proxy_source)
20-
if proxy_source in self.upstream_urls:
21-
self.upstream = self.upstream_urls[proxy_source]
2235

23-
return super(BCTileserverLocalProxyView, self).get_request_headers()
36+
# Get all available sources
37+
upstream_urls = self.upstream_urls
2438

39+
# Get source configuration with fallback to default source
40+
source_config = (
41+
upstream_urls[proxy_source]
42+
if proxy_source in upstream_urls
43+
else upstream_urls[self.DEFAULT_SOURCE]
44+
)
2545

26-
class BCTileserverProxyView(BCTileserverLocalProxyView):
27-
"""
28-
Subclass of the TileserverProxyView that has multiple upstream servers.
29-
- the BC_TILESERVER_URLS is a dict with source->upstream URL mapping
30-
- the request URL must have the source parameter set. If not set it defaults to the parent upstream value
31-
"""
46+
# Update upstream URL from configuration
47+
self.upstream = source_config.get("url")
48+
49+
# Configure the HTTP connection based on proxy setting
50+
self.http = self.get_http_connection(
51+
use_outbound_proxy=source_config.get("use_outbound_proxy", False)
52+
)
53+
54+
return super(BCTileserverProxyView, self).get_request_headers()
3255

33-
def __init__(self, *args, **kwargs):
34-
super(BCTileserverProxyView, self).__init__(*args, **kwargs)
35-
# Setup outbound proxy if it is configured
36-
if (
37-
hasattr(settings, "TILESERVER_OUTBOUND_PROXY")
38-
and settings.TILESERVER_OUTBOUND_PROXY
39-
):
40-
# print("Setting outbound proxy to %s" % settings.TILESERVER_OUTBOUND_PROXY)
41-
self.http = urllib3.ProxyManager(settings.TILESERVER_OUTBOUND_PROXY)
56+
def dispatch(self, request, *args, **kwargs):
57+
# When hitting /bctileserver/ there is no <path:path> kwarg.
58+
# Normalize to empty string so upstream becomes "/" not "None".
59+
kwargs["path"] = kwargs.get("path", "")
60+
return super().dispatch(request, *args, **kwargs)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# %%
2+
import urllib3
3+
from django.conf import settings
4+
5+
6+
class OutboundProxyMixin:
7+
"""
8+
Mixin that adds outbound proxy functionality to a view.
9+
This can be used by proxy views to route requests through an external proxy.
10+
"""
11+
12+
def get_http_connection(self, use_outbound_proxy=False):
13+
"""
14+
Returns the appropriate HTTP connection based on whether
15+
to use the outbound proxy or not.
16+
"""
17+
if (
18+
use_outbound_proxy
19+
and hasattr(settings, "TILESERVER_OUTBOUND_PROXY")
20+
and settings.TILESERVER_OUTBOUND_PROXY
21+
):
22+
# Return proxy connection
23+
return urllib3.ProxyManager(settings.TILESERVER_OUTBOUND_PROXY)
24+
else:
25+
# Return regular connection
26+
return urllib3.PoolManager()
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from django.test import TestCase
2+
from unittest.mock import patch, MagicMock
3+
from django.conf import settings
4+
import urllib3
5+
from bcgov_arches_common.views.outbound_proxy_mixin import OutboundProxyMixin
6+
7+
8+
class OutboundProxyMixinTests(TestCase):
9+
def setUp(self):
10+
"""
11+
Set up the test environment. This method will run before every test.
12+
"""
13+
self.mixin = OutboundProxyMixin()
14+
15+
@patch("bcgov_arches_common.views.outbound_proxy_mixin.urllib3.ProxyManager")
16+
@patch("bcgov_arches_common.views.outbound_proxy_mixin.urllib3.PoolManager")
17+
def test_get_http_connection_with_outbound_proxy(
18+
self, mock_pool_manager, mock_proxy_manager
19+
):
20+
"""
21+
Test that get_http_connection uses ProxyManager when an outbound proxy is specified.
22+
"""
23+
settings.TILESERVER_OUTBOUND_PROXY = "http://example-proxy.com"
24+
25+
# Call the method
26+
connection = self.mixin.get_http_connection(use_outbound_proxy=True)
27+
28+
# Assert that ProxyManager was called with the correct argument
29+
mock_proxy_manager.assert_called_once_with(settings.TILESERVER_OUTBOUND_PROXY)
30+
mock_pool_manager.assert_not_called()
31+
32+
# Assert that the returned connection is a ProxyManager instance
33+
self.assertEqual(connection, mock_proxy_manager.return_value)
34+
35+
@patch("bcgov_arches_common.views.outbound_proxy_mixin.urllib3.PoolManager")
36+
@patch("bcgov_arches_common.views.outbound_proxy_mixin.urllib3.ProxyManager")
37+
def test_get_http_connection_without_outbound_proxy(
38+
self, mock_proxy_manager, mock_pool_manager
39+
):
40+
"""
41+
Test that get_http_connection uses PoolManager when no outbound proxy is specified.
42+
"""
43+
# Ensure TILESERVER_OUTBOUND_PROXY is not set
44+
settings.TILESERVER_OUTBOUND_PROXY = None
45+
46+
# Call the method
47+
connection = self.mixin.get_http_connection(use_outbound_proxy=False)
48+
49+
# Assert that PoolManager was called and ProxyManager was not
50+
mock_pool_manager.assert_called_once()
51+
mock_proxy_manager.assert_not_called()
52+
53+
# Assert that the returned connection is a PoolManager instance
54+
self.assertEqual(connection, mock_pool_manager.return_value)
55+
56+
@patch("bcgov_arches_common.views.outbound_proxy_mixin.urllib3.PoolManager")
57+
@patch("bcgov_arches_common.views.outbound_proxy_mixin.urllib3.ProxyManager")
58+
def test_get_http_connection_with_proxy_config_disabled(
59+
self, mock_proxy_manager, mock_pool_manager
60+
):
61+
"""
62+
Test that get_http_connection ignores outbound proxy settings if use_outbound_proxy is False.
63+
"""
64+
settings.TILESERVER_OUTBOUND_PROXY = "http://example-proxy.com"
65+
66+
# Call the method
67+
connection = self.mixin.get_http_connection(use_outbound_proxy=False)
68+
69+
# Ensure PoolManager was called instead of ProxyManager
70+
mock_pool_manager.assert_called_once()
71+
mock_proxy_manager.assert_not_called()
72+
73+
# Assert the connection is a PoolManager instance
74+
self.assertEqual(connection, mock_pool_manager.return_value)

0 commit comments

Comments
 (0)