pyobs-auth 2.0.0.dev10__tar.gz → 2.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyobs_auth-2.0.0.dev10 → pyobs_auth-2.1.0}/PKG-INFO +1 -1
- {pyobs_auth-2.0.0.dev10 → pyobs_auth-2.1.0}/pyobs_auth/authentication.py +21 -5
- pyobs_auth-2.1.0/pyobs_auth/authorization.py +61 -0
- {pyobs_auth-2.0.0.dev10 → pyobs_auth-2.1.0}/pyobs_auth/client.py +25 -3
- pyobs_auth-2.1.0/pyobs_auth/middleware.py +152 -0
- {pyobs_auth-2.0.0.dev10 → pyobs_auth-2.1.0}/pyobs_auth/settings.py +29 -0
- {pyobs_auth-2.0.0.dev10 → pyobs_auth-2.1.0}/pyobs_auth/views.py +42 -1
- {pyobs_auth-2.0.0.dev10 → pyobs_auth-2.1.0}/pyproject.toml +1 -1
- {pyobs_auth-2.0.0.dev10 → pyobs_auth-2.1.0}/.gitignore +0 -0
- {pyobs_auth-2.0.0.dev10 → pyobs_auth-2.1.0}/LICENSE +0 -0
- {pyobs_auth-2.0.0.dev10 → pyobs_auth-2.1.0}/pyobs_auth/__init__.py +0 -0
- {pyobs_auth-2.0.0.dev10 → pyobs_auth-2.1.0}/pyobs_auth/apps.py +0 -0
- {pyobs_auth-2.0.0.dev10 → pyobs_auth-2.1.0}/pyobs_auth/discovery.py +0 -0
- {pyobs_auth-2.0.0.dev10 → pyobs_auth-2.1.0}/pyobs_auth/py.typed +0 -0
- {pyobs_auth-2.0.0.dev10 → pyobs_auth-2.1.0}/pyobs_auth/templates/pyobs_auth/error.html +0 -0
- {pyobs_auth-2.0.0.dev10 → pyobs_auth-2.1.0}/pyobs_auth/urls.py +0 -0
- {pyobs_auth-2.0.0.dev10 → pyobs_auth-2.1.0}/pyobs_auth/validation.py +0 -0
|
@@ -9,19 +9,25 @@ issuer.
|
|
|
9
9
|
This class is written to be safe to stack alongside another Bearer-scheme authenticator that
|
|
10
10
|
can't be modified (e.g. an existing legacy OAuth2 BearerAuthentication) - see below.
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
The primary authorization gate is claims-based (`PYOBS_AUTH['REQUIRED_GROUPS']`/`REQUIRED_ROLES'`,
|
|
13
|
+
see `pyobs_auth.authorization`) - Keycloak group/role membership carried in the token, checked
|
|
14
|
+
before the user's first login even needs to happen. The local `is_active` check only applies when
|
|
15
|
+
`PYOBS_AUTH['ENFORCE_LOCAL_ACTIVE']` is set, preserving it as an opt-in, Keycloak-independent kill
|
|
16
|
+
switch rather than the default gate.
|
|
16
17
|
"""
|
|
17
18
|
|
|
18
19
|
from __future__ import annotations
|
|
19
20
|
|
|
21
|
+
import logging
|
|
22
|
+
|
|
20
23
|
from rest_framework import authentication, exceptions
|
|
21
24
|
|
|
25
|
+
from .authorization import AuthorizationError, authorize
|
|
22
26
|
from .settings import get_settings
|
|
23
27
|
from .validation import TokenValidationError, TokenValidator
|
|
24
28
|
|
|
29
|
+
_logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
25
31
|
|
|
26
32
|
class KeycloakAuthentication(authentication.BaseAuthentication):
|
|
27
33
|
www_authenticate_realm = "api"
|
|
@@ -51,11 +57,21 @@ class KeycloakAuthentication(authentication.BaseAuthentication):
|
|
|
51
57
|
except TokenValidationError as exc:
|
|
52
58
|
raise exceptions.AuthenticationFailed(str(exc)) from exc
|
|
53
59
|
|
|
60
|
+
# Checked before resolving a local user - an unauthorized caller must not mint a User
|
|
61
|
+
# row just by presenting a validly-signed-but-ungrouped token.
|
|
62
|
+
try:
|
|
63
|
+
authorize(claims, settings)
|
|
64
|
+
except AuthorizationError as exc:
|
|
65
|
+
raise exceptions.AuthenticationFailed(str(exc)) from exc
|
|
66
|
+
except ValueError:
|
|
67
|
+
_logger.exception("PYOBS_AUTH['REQUIRED_ROLES'] is malformed")
|
|
68
|
+
raise
|
|
69
|
+
|
|
54
70
|
user_resolver = settings.resolve_user_callable()
|
|
55
71
|
user = user_resolver(claims)
|
|
56
72
|
if user is None:
|
|
57
73
|
raise exceptions.AuthenticationFailed("No local user for this token")
|
|
58
|
-
if not user.is_active:
|
|
74
|
+
if settings.enforce_local_active and not user.is_active:
|
|
59
75
|
raise exceptions.AuthenticationFailed("Account pending activation")
|
|
60
76
|
|
|
61
77
|
return (user, claims)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Claims-based authorization gate: Keycloak group/role membership as the authorization source
|
|
2
|
+
of truth, replacing per-service local `is_active` activation. See pyobs-core's
|
|
3
|
+
`specs/design/shared-authz-keycloak.md` and ADR `0014-centralized-authorization-via-keycloak-groups.md`
|
|
4
|
+
for the reasoning.
|
|
5
|
+
|
|
6
|
+
Neither `REQUIRED_GROUPS` nor `REQUIRED_ROLES` set -> `authorize()` always passes (opt-in gate,
|
|
7
|
+
current behavior unchanged). Both set -> both must pass (AND, not OR) - there's no fleet use case
|
|
8
|
+
today that needs "either this group or that role", and OR would be a silent surprise for whoever
|
|
9
|
+
sets both expecting an AND.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .settings import KeycloakSettings
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuthorizationError(Exception):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _has_required_groups(claims: dict[str, Any], required_groups: tuple[str, ...]) -> bool:
|
|
24
|
+
groups = set(claims.get("groups") or [])
|
|
25
|
+
return all(group in groups for group in required_groups)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _has_required_roles(claims: dict[str, Any], required_roles: tuple[str, ...]) -> bool:
|
|
29
|
+
realm_roles = set((claims.get("realm_access") or {}).get("roles") or [])
|
|
30
|
+
resource_access = claims.get("resource_access") or {}
|
|
31
|
+
|
|
32
|
+
for required in required_roles:
|
|
33
|
+
kind, _, name = required.partition(":")
|
|
34
|
+
if kind == "realm":
|
|
35
|
+
if name not in realm_roles:
|
|
36
|
+
return False
|
|
37
|
+
elif kind == "client":
|
|
38
|
+
client_id, _, role = name.partition(":")
|
|
39
|
+
client_roles = set((resource_access.get(client_id) or {}).get("roles") or [])
|
|
40
|
+
if role not in client_roles:
|
|
41
|
+
return False
|
|
42
|
+
else:
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"malformed PYOBS_AUTH['REQUIRED_ROLES'] entry {required!r} -"
|
|
45
|
+
" expected 'realm:<role>' or 'client:<client_id>:<role>'"
|
|
46
|
+
)
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def authorize(claims: dict[str, Any], settings: KeycloakSettings) -> None:
|
|
51
|
+
"""Raise AuthorizationError unless `claims` satisfies every configured REQUIRED_GROUPS entry
|
|
52
|
+
(full group paths, e.g. `/pyobs-archive`) and every configured REQUIRED_ROLES entry (realm
|
|
53
|
+
roles as `realm:<role>`, client roles as `client:<client_id>:<role>`).
|
|
54
|
+
"""
|
|
55
|
+
if not settings.required_groups and not settings.required_roles:
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
if not _has_required_groups(claims, settings.required_groups):
|
|
59
|
+
raise AuthorizationError("not authorized")
|
|
60
|
+
if not _has_required_roles(claims, settings.required_roles):
|
|
61
|
+
raise AuthorizationError("not authorized")
|
|
@@ -11,13 +11,22 @@ from typing import Any
|
|
|
11
11
|
from urllib.parse import urlencode
|
|
12
12
|
|
|
13
13
|
import requests
|
|
14
|
+
from requests.exceptions import RequestException
|
|
14
15
|
|
|
15
16
|
from .discovery import fetch_discovery_document
|
|
16
17
|
from .settings import KeycloakSettings
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
class TokenExchangeError(Exception):
|
|
20
|
-
|
|
21
|
+
"""`error_code` is the OAuth `error` field from the token endpoint's JSON body, when the
|
|
22
|
+
endpoint responded at all (e.g. "invalid_grant" for a revoked/expired/reused refresh token).
|
|
23
|
+
None for a connection-level failure or a non-JSON/unrecognized response - callers that need to
|
|
24
|
+
tell "genuinely revoked" apart from "couldn't even ask" should check this rather than assume
|
|
25
|
+
every TokenExchangeError means the former."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, message: str, *, error_code: str | None = None) -> None:
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.error_code = error_code
|
|
21
30
|
|
|
22
31
|
|
|
23
32
|
def _b64url(raw: bytes) -> str:
|
|
@@ -137,7 +146,20 @@ class KeycloakClient:
|
|
|
137
146
|
return f"{document.end_session_endpoint}?{urlencode(params)}"
|
|
138
147
|
|
|
139
148
|
def _post_token(self, token_endpoint: str, data: dict[str, str]) -> dict[str, Any]:
|
|
140
|
-
|
|
149
|
+
try:
|
|
150
|
+
response = self._session.post(token_endpoint, data=data, timeout=10.0)
|
|
151
|
+
except RequestException as exc:
|
|
152
|
+
raise TokenExchangeError(f"could not reach token endpoint: {exc}") from exc
|
|
153
|
+
|
|
141
154
|
if response.status_code != 200:
|
|
142
|
-
|
|
155
|
+
error_code = None
|
|
156
|
+
try:
|
|
157
|
+
body = response.json()
|
|
158
|
+
except ValueError:
|
|
159
|
+
body = None
|
|
160
|
+
if isinstance(body, dict):
|
|
161
|
+
error_code = body.get("error")
|
|
162
|
+
raise TokenExchangeError(
|
|
163
|
+
f"token endpoint returned {response.status_code}: {response.text}", error_code=error_code
|
|
164
|
+
)
|
|
143
165
|
return response.json()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Session-refresh middleware: once the access token that established a browser session has
|
|
2
|
+
expired, silently exchange the stored refresh token for a new one, re-validate the resulting
|
|
3
|
+
claims, and re-run the authorization gate - ending the session if it now fails.
|
|
4
|
+
|
|
5
|
+
Without this, a plain Django session never re-contacts Keycloak after login, so a revoked group/
|
|
6
|
+
role (or a synced local flag like `is_superuser`) would only take effect at the user's next login,
|
|
7
|
+
bounded only by `SESSION_COOKIE_AGE`, not by any token lifetime. See pyobs-core's
|
|
8
|
+
`specs/design/shared-authz-keycloak.md`, "Revocation model and freshness".
|
|
9
|
+
|
|
10
|
+
Runs lazily, on whichever request happens to land after expiry - no background task, and no
|
|
11
|
+
Keycloak round trip at all while the cached access token is still valid, so this doesn't add a
|
|
12
|
+
per-request network dependency.
|
|
13
|
+
|
|
14
|
+
Add to `MIDDLEWARE`, after `AuthenticationMiddleware` (needs `request.user`)::
|
|
15
|
+
|
|
16
|
+
MIDDLEWARE = [
|
|
17
|
+
...,
|
|
18
|
+
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
19
|
+
"pyobs_auth.middleware.KeycloakSessionRefreshMiddleware",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
A no-op for any request whose session doesn't carry both a refresh token and an access-token
|
|
23
|
+
expiry - i.e. sessions never established via `CallbackView` (a local-password session), sessions
|
|
24
|
+
established before this middleware existed, and sessions where `CallbackView` declined to store
|
|
25
|
+
the refresh token at all (a cookie-backed `SESSION_ENGINE` - see `views.py`).
|
|
26
|
+
|
|
27
|
+
A failed refresh only ends the session when Keycloak's token endpoint says the grant is actually
|
|
28
|
+
invalid (`error: "invalid_grant"`) - a network error or a 5xx from Keycloak itself leaves the
|
|
29
|
+
session as-is and lets the next request retry, so a Keycloak outage doesn't mass-log-out every
|
|
30
|
+
active session. `invalid_grant` itself is ambiguous between "genuinely revoked" and "a harmless
|
|
31
|
+
race between two concurrent requests both refreshing the same soon-to-be-invalidated token" (only
|
|
32
|
+
relevant if the realm has refresh-token rotation/revocation enabled) - handled by re-reading the
|
|
33
|
+
session from its store before giving up.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import logging
|
|
39
|
+
import time
|
|
40
|
+
from collections.abc import Callable
|
|
41
|
+
from importlib import import_module
|
|
42
|
+
|
|
43
|
+
from django.conf import settings as django_settings
|
|
44
|
+
from django.contrib.auth import logout
|
|
45
|
+
from django.http import HttpRequest, HttpResponse
|
|
46
|
+
|
|
47
|
+
from .authorization import AuthorizationError, authorize
|
|
48
|
+
from .client import KeycloakClient, TokenExchangeError
|
|
49
|
+
from .settings import get_settings
|
|
50
|
+
from .validation import TokenValidationError, TokenValidator
|
|
51
|
+
from .views import SESSION_ACCESS_EXPIRES_KEY, SESSION_REFRESH_TOKEN_KEY
|
|
52
|
+
|
|
53
|
+
_logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class KeycloakSessionRefreshMiddleware:
|
|
57
|
+
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
|
|
58
|
+
self.get_response = get_response
|
|
59
|
+
|
|
60
|
+
def __call__(self, request: HttpRequest) -> HttpResponse:
|
|
61
|
+
self._maybe_refresh(request)
|
|
62
|
+
return self.get_response(request)
|
|
63
|
+
|
|
64
|
+
def _maybe_refresh(self, request: HttpRequest) -> None:
|
|
65
|
+
expires_at = request.session.get(SESSION_ACCESS_EXPIRES_KEY)
|
|
66
|
+
refresh_token = request.session.get(SESSION_REFRESH_TOKEN_KEY)
|
|
67
|
+
if expires_at is None or refresh_token is None:
|
|
68
|
+
return
|
|
69
|
+
if time.time() < expires_at:
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
settings = get_settings()
|
|
73
|
+
client = KeycloakClient(settings)
|
|
74
|
+
try:
|
|
75
|
+
tokens = client.refresh(refresh_token=refresh_token)
|
|
76
|
+
except TokenExchangeError as exc:
|
|
77
|
+
self._handle_refresh_failure(request, exc)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
access_token = tokens.get("access_token")
|
|
81
|
+
if not access_token:
|
|
82
|
+
logout(request)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
validator = TokenValidator(settings)
|
|
86
|
+
try:
|
|
87
|
+
claims = validator.validate(access_token)
|
|
88
|
+
except TokenValidationError:
|
|
89
|
+
logout(request)
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
if settings.enforce_local_active and not request.user.is_active:
|
|
93
|
+
logout(request)
|
|
94
|
+
return
|
|
95
|
+
try:
|
|
96
|
+
authorize(claims, settings)
|
|
97
|
+
except AuthorizationError:
|
|
98
|
+
logout(request)
|
|
99
|
+
return
|
|
100
|
+
except ValueError:
|
|
101
|
+
_logger.exception("PYOBS_AUTH['REQUIRED_ROLES'] is malformed")
|
|
102
|
+
raise
|
|
103
|
+
|
|
104
|
+
# Re-run the resolver so a claim-derived local flag (e.g. portal's `is_superuser` synced
|
|
105
|
+
# from a client role) picks up a change made in Keycloak since login, not just at next
|
|
106
|
+
# login - and so the *current* request sees it too, not only the next one.
|
|
107
|
+
user_resolver = settings.resolve_user_callable()
|
|
108
|
+
refreshed_user = user_resolver(claims)
|
|
109
|
+
if refreshed_user is None:
|
|
110
|
+
logout(request)
|
|
111
|
+
return
|
|
112
|
+
request.user = refreshed_user
|
|
113
|
+
|
|
114
|
+
request.session[SESSION_ACCESS_EXPIRES_KEY] = claims["exp"]
|
|
115
|
+
new_refresh_token = tokens.get("refresh_token")
|
|
116
|
+
if new_refresh_token:
|
|
117
|
+
request.session[SESSION_REFRESH_TOKEN_KEY] = new_refresh_token
|
|
118
|
+
|
|
119
|
+
def _handle_refresh_failure(self, request: HttpRequest, exc: TokenExchangeError) -> None:
|
|
120
|
+
if exc.error_code != "invalid_grant":
|
|
121
|
+
# Keycloak unreachable, a 5xx, a malformed response, etc. - not evidence of
|
|
122
|
+
# revocation. Leave the session as-is; the next request retries. No backoff/
|
|
123
|
+
# rate-limiting here - retrying every request during an outage is an accepted
|
|
124
|
+
# tradeoff at this fleet's scale. debug, not warning: during an outage this fires
|
|
125
|
+
# once per expired session per request, which would otherwise flood logs.
|
|
126
|
+
_logger.debug("Keycloak refresh_token grant failed (not invalid_grant): %s", exc)
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
# invalid_grant can mean a genuinely revoked/expired grant, or a benign race: two
|
|
130
|
+
# concurrent requests both saw the access token as expired and both tried to refresh the
|
|
131
|
+
# same refresh token - if the realm rotates/revokes refresh tokens on use, only the first
|
|
132
|
+
# succeeds and the second gets invalid_grant even though nothing was actually revoked.
|
|
133
|
+
# Re-read the session fresh from its store (not the in-memory request.session, which may
|
|
134
|
+
# be stale relative to what the concurrent request already wrote) before giving up.
|
|
135
|
+
fresh = self._fresh_session(request)
|
|
136
|
+
fresh_expires_at = fresh.get(SESSION_ACCESS_EXPIRES_KEY) if fresh is not None else None
|
|
137
|
+
if fresh_expires_at is not None and fresh_expires_at > time.time():
|
|
138
|
+
request.session[SESSION_ACCESS_EXPIRES_KEY] = fresh_expires_at
|
|
139
|
+
fresh_refresh_token = fresh.get(SESSION_REFRESH_TOKEN_KEY)
|
|
140
|
+
if fresh_refresh_token is not None:
|
|
141
|
+
request.session[SESSION_REFRESH_TOKEN_KEY] = fresh_refresh_token
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
logout(request)
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def _fresh_session(request: HttpRequest):
|
|
148
|
+
session_key = request.session.session_key
|
|
149
|
+
if not session_key:
|
|
150
|
+
return None
|
|
151
|
+
engine = import_module(django_settings.SESSION_ENGINE)
|
|
152
|
+
return engine.SessionStore(session_key=session_key)
|
|
@@ -11,6 +11,10 @@ Read from the Django ``PYOBS_AUTH`` setting, e.g.::
|
|
|
11
11
|
"POST_LOGOUT_REDIRECT_URI": "https://archive.example.org/",
|
|
12
12
|
# dotted path to a callable(claims: dict) -> django.contrib.auth.models.User
|
|
13
13
|
"USER_RESOLVER": "pyobs_archive.authentication.keycloak.resolve_user",
|
|
14
|
+
# optional claims-based authorization gate - see pyobs_auth.authorization
|
|
15
|
+
"REQUIRED_GROUPS": ["/pyobs-archive"],
|
|
16
|
+
"REQUIRED_ROLES": ["client:archive:archive-admin"],
|
|
17
|
+
"ENFORCE_LOCAL_ACTIVE": False,
|
|
14
18
|
}
|
|
15
19
|
"""
|
|
16
20
|
|
|
@@ -34,6 +38,9 @@ class KeycloakSettings:
|
|
|
34
38
|
scopes: tuple[str, ...] = field(default_factory=lambda: ("openid", "profile", "email"))
|
|
35
39
|
user_resolver: str | None = None
|
|
36
40
|
idp_hint: str | None = None
|
|
41
|
+
required_groups: tuple[str, ...] = field(default_factory=tuple)
|
|
42
|
+
required_roles: tuple[str, ...] = field(default_factory=tuple)
|
|
43
|
+
enforce_local_active: bool = False
|
|
37
44
|
|
|
38
45
|
@property
|
|
39
46
|
def issuer(self) -> str:
|
|
@@ -64,6 +71,25 @@ class ImproperlyConfiguredError(Exception):
|
|
|
64
71
|
pass
|
|
65
72
|
|
|
66
73
|
|
|
74
|
+
def _as_tuple(value: Any) -> tuple[str, ...]:
|
|
75
|
+
"""A bare string (a common mistake for a "one group" REQUIRED_GROUPS/REQUIRED_ROLES value)
|
|
76
|
+
would otherwise silently `tuple()`-split into individual characters instead of raising -
|
|
77
|
+
normalize it to a one-element tuple instead."""
|
|
78
|
+
if value is None:
|
|
79
|
+
return ()
|
|
80
|
+
if isinstance(value, str):
|
|
81
|
+
return (value,)
|
|
82
|
+
return tuple(value)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _as_bool(value: Any) -> bool:
|
|
86
|
+
"""`bool("False")` is True - a string value (e.g. from naive env-var parsing) needs an
|
|
87
|
+
explicit check rather than Python's default truthiness."""
|
|
88
|
+
if isinstance(value, str):
|
|
89
|
+
return value.strip().lower() in ("true", "1", "yes")
|
|
90
|
+
return bool(value)
|
|
91
|
+
|
|
92
|
+
|
|
67
93
|
def get_settings() -> KeycloakSettings:
|
|
68
94
|
"""Build KeycloakSettings from Django's PYOBS_AUTH setting."""
|
|
69
95
|
from django.conf import settings as django_settings
|
|
@@ -87,4 +113,7 @@ def get_settings() -> KeycloakSettings:
|
|
|
87
113
|
scopes=tuple(raw.get("SCOPES", ("openid", "profile", "email"))),
|
|
88
114
|
user_resolver=raw.get("USER_RESOLVER"),
|
|
89
115
|
idp_hint=raw.get("IDP_HINT"),
|
|
116
|
+
required_groups=_as_tuple(raw.get("REQUIRED_GROUPS")),
|
|
117
|
+
required_roles=_as_tuple(raw.get("REQUIRED_ROLES")),
|
|
118
|
+
enforce_local_active=_as_bool(raw.get("ENFORCE_LOCAL_ACTIVE", False)),
|
|
90
119
|
)
|
|
@@ -7,22 +7,31 @@ plain Django views instead; wire them in via pyobs_auth.urls.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import logging
|
|
11
|
+
|
|
10
12
|
from django.conf import settings as django_settings
|
|
11
13
|
from django.contrib.auth import login, logout
|
|
12
14
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
|
13
15
|
from django.shortcuts import render
|
|
14
16
|
from django.views import View
|
|
15
17
|
|
|
18
|
+
from .authorization import AuthorizationError, authorize
|
|
16
19
|
from .client import KeycloakClient, TokenExchangeError
|
|
17
20
|
from .settings import get_settings
|
|
18
21
|
from .validation import TokenValidationError, TokenValidator
|
|
19
22
|
|
|
23
|
+
_logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
20
25
|
SESSION_STATE_KEY = "pyobs_auth_state"
|
|
21
26
|
SESSION_CODE_VERIFIER_KEY = "pyobs_auth_code_verifier"
|
|
22
27
|
SESSION_NEXT_KEY = "pyobs_auth_next"
|
|
23
28
|
# Presence of this key is also how LogoutView tells "this session came from Keycloak" apart from
|
|
24
29
|
# a plain local-password session, so it knows whether to also end the Keycloak SSO session.
|
|
25
30
|
SESSION_ID_TOKEN_KEY = "pyobs_auth_id_token"
|
|
31
|
+
# Both read by KeycloakSessionRefreshMiddleware - absence of either means this session predates
|
|
32
|
+
# the middleware or wasn't established via Keycloak, so it leaves the session alone.
|
|
33
|
+
SESSION_REFRESH_TOKEN_KEY = "pyobs_auth_refresh_token"
|
|
34
|
+
SESSION_ACCESS_EXPIRES_KEY = "pyobs_auth_access_expires"
|
|
26
35
|
|
|
27
36
|
|
|
28
37
|
def _error_response(request: HttpRequest, message: str) -> HttpResponse:
|
|
@@ -86,11 +95,21 @@ class CallbackView(View):
|
|
|
86
95
|
except TokenValidationError as exc:
|
|
87
96
|
return _error_response(request, f"Received an invalid token: {exc}")
|
|
88
97
|
|
|
98
|
+
# Checked before resolving a local user - an unauthorized caller must not mint a User
|
|
99
|
+
# row just by presenting a validly-signed-but-ungrouped token.
|
|
100
|
+
try:
|
|
101
|
+
authorize(claims, settings)
|
|
102
|
+
except AuthorizationError:
|
|
103
|
+
return _error_response(request, "You are not authorized to use this service. Contact an administrator.")
|
|
104
|
+
except ValueError:
|
|
105
|
+
_logger.exception("PYOBS_AUTH['REQUIRED_ROLES'] is malformed")
|
|
106
|
+
raise
|
|
107
|
+
|
|
89
108
|
user_resolver = settings.resolve_user_callable()
|
|
90
109
|
user = user_resolver(claims)
|
|
91
110
|
if user is None:
|
|
92
111
|
return _error_response(request, "No local user for this token")
|
|
93
|
-
if not user.is_active:
|
|
112
|
+
if settings.enforce_local_active and not user.is_active:
|
|
94
113
|
return _error_response(request, "This account is pending activation. Contact an administrator.")
|
|
95
114
|
|
|
96
115
|
backend = getattr(django_settings, "PYOBS_AUTH_LOGIN_BACKEND", "django.contrib.auth.backends.ModelBackend")
|
|
@@ -100,6 +119,28 @@ class CallbackView(View):
|
|
|
100
119
|
id_token = tokens.get("id_token")
|
|
101
120
|
if id_token:
|
|
102
121
|
request.session[SESSION_ID_TOKEN_KEY] = id_token
|
|
122
|
+
# Kept so KeycloakSessionRefreshMiddleware can silently refresh and re-authorize once the
|
|
123
|
+
# access token expires, instead of a browser session outliving revocation until logout -
|
|
124
|
+
# see pyobs-core's shared-authz-keycloak.md "Revocation model and freshness".
|
|
125
|
+
refresh_token = tokens.get("refresh_token")
|
|
126
|
+
if refresh_token:
|
|
127
|
+
session_engine = getattr(django_settings, "SESSION_ENGINE", "django.contrib.sessions.backends.db")
|
|
128
|
+
if session_engine.endswith(".signed_cookies"):
|
|
129
|
+
# A cookie-backed session serializes into the browser - signed, but not
|
|
130
|
+
# encrypted, so storing a bearer credential that can mint fresh access tokens
|
|
131
|
+
# indefinitely there would hand it to the client. Skip storing it; the session
|
|
132
|
+
# simply won't be refreshable (KeycloakSessionRefreshMiddleware is a no-op for
|
|
133
|
+
# it), same as before this feature existed.
|
|
134
|
+
_logger.warning(
|
|
135
|
+
"SESSION_ENGINE=%s is cookie-backed - not storing the Keycloak refresh token "
|
|
136
|
+
"in the session. KeycloakSessionRefreshMiddleware will be a no-op for this "
|
|
137
|
+
"session; group/role revocation only takes effect at next login. Switch to a "
|
|
138
|
+
"server-side SESSION_ENGINE (db/cached_db) to enable session refresh.",
|
|
139
|
+
session_engine,
|
|
140
|
+
)
|
|
141
|
+
else:
|
|
142
|
+
request.session[SESSION_REFRESH_TOKEN_KEY] = refresh_token
|
|
143
|
+
request.session[SESSION_ACCESS_EXPIRES_KEY] = claims["exp"]
|
|
103
144
|
|
|
104
145
|
return HttpResponseRedirect(next_url)
|
|
105
146
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pyobs-auth"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.1.0"
|
|
4
4
|
description = "Shared Keycloak/OIDC authentication client for pyobs web services"
|
|
5
5
|
authors = [{ name = "Tim-Oliver Husser", email = "thusser@uni-goettingen.de" }]
|
|
6
6
|
requires-python = ">=3.11"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|