Skip to content

Commit

Permalink
Merge pull request #230 from TeskaLabs/fix/grafana-batman
Browse files Browse the repository at this point in the history
Fix Batman token encryption
  • Loading branch information
byewokko authored Jul 11, 2023
2 parents 19d88cd + 2f34360 commit 64eab67
Show file tree
Hide file tree
Showing 11 changed files with 61 additions and 44 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@

## Release candidate

### Breaking changes
- Old Batman sessions are invalidated (#230, PLUM Sprint 230630)

### Fix
- Root session must be as long as its longest subsession (#228, PLUM Sprint 230630)
- Webauthn `user_name` can be either email address or phone number (#229, PLUM Sprint 230630)
- Batman token uses native ASAB Storage encryption (#230, PLUM Sprint 230630)

### Features
- Added alternative POST endpoint for Batman introspection (#230, PLUM Sprint 230630)

---

Expand Down
20 changes: 13 additions & 7 deletions docs/integrations/elk.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ password=elasticpassword
### Client configuration

Use Seacat Auth client API (or Seacat Admin UI) to register Kibana as a client.
In our case, we can send the following request:
The request body must include a human-readable `client_name`, `redirect_uris` array containing the URL of Kibana web UI
and `cookie_entry_uri` for your hostname (we define this location in the Nginx configuration below.).
We also recommend to set `redirect_uri_validation_method` to `prefix_match` if you want to allow immediate redirections
to Kibana subpaths.
In our case, we can send the following request (Remember to use your actual hostnames instead of `example.com`!):

```
POST /client
Expand All @@ -56,7 +60,8 @@ POST /client
"redirect_uri_validation_method": "prefix_match",
"redirect_uris": [
"https://example.com/kibana"
]
],
"cookie_entry_uri": "https://example.com/seacat_auth/cookie"
}
```

Expand Down Expand Up @@ -98,7 +103,7 @@ location /kibana/ {
proxy_set_header Authorization $auth_header;
# In the case when introspection detects invalid authorization, redirect to OAuth authorize endpoint
# !! Use your client's actual client_id !!
# !! Use your client's actual client_id and your site's actual hostname !!
error_page 401 https://example.com/auth/api/openidconnect/authorize?response_type=code&scope=cookie%20batman&client_id=RZhlE-D4yuJxoKitYVL4dg&redirect_uri=https://example.com$request_uri;
# Headers required by Kibana
Expand Down Expand Up @@ -136,19 +141,20 @@ location = /_kibana_introspection {
}
```

#### Client cookie entry point
#### Cookie entry point

Must be located on the same hostname as the protected client location.
Must be located on the same hostname as the protected client location.
There should be one cookie entry point exposed per hostname, shared by all cookie-based clients on that hostname.

```nginx
location = /auth/api/cookie/kibana {
location = /seacat_auth/cookie {
# Seacat Auth cookie entry upstream
proxy_method POST;
proxy_pass http://seacat_auth_api/cookie/entry;
# Transfer the OAuth authorization code from query to request body
# !! Use your client's actual client_id !!
proxy_set_header Content-Type "application/x-www-form-urlencoded";
proxy_set_body "client_id=RZhlE-D4yuJxoKitYVL4dg&grant_type=authorization_code&code=$arg_code";
proxy_set_body $args;
}
```
3 changes: 0 additions & 3 deletions seacatauth/authn/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,9 +315,6 @@ async def logout(self, request):
else:
set_cookie(self.App, response, impersonator_session)

if self.BatmanService is not None:
response.del_cookie(self.BatmanService.CookieName)

return response

async def smslogin(self, request):
Expand Down
2 changes: 1 addition & 1 deletion seacatauth/authn/m2m.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ async def authenticate_request(self, request, client_id):

# Find session object
try:
session = await self.SessionService.get_by({SessionAdapter.FN.Credentials.Id: credentials_id})
session = await self.SessionService.get_by(SessionAdapter.FN.Credentials.Id, credentials_id)
except KeyError:
session = None

Expand Down
11 changes: 7 additions & 4 deletions seacatauth/batman/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ def __init__(self, app, batman_svc):

web_app = app.WebContainer.WebApp
web_app.router.add_put("/batman/nginx", self.batman_nginx)
web_app.router.add_post("/batman/nginx", self.batman_nginx)

# Public endpoints
web_app_public.router.add_put("/batman/nginx", self.batman_nginx)
web_app_public.router.add_post("/batman/nginx", self.batman_nginx)


async def batman_nginx(self, request):
Expand All @@ -37,11 +39,12 @@ async def batman_nginx(self, request):
**Internal endpoint for Nginx auth_request.**
"""
cookie_service = self.BatmanService.App.get_service("seacatauth.CookieService")
cookie_value = cookie_service.get_session_cookie_value(request, request.query.get("client_id"))
if cookie_value is None:
return aiohttp.web.HTTPUnauthorized()

session = await cookie_service.get_session_by_session_cookie_value(cookie_value)
client_id = request.query.get("client_id")
if client_id is None:
raise ValueError("No 'client_id' parameter specified in Batman introspection query.")

session = await cookie_service.get_session_by_request_cookie(request, request.query.get("client_id"))
if session is None or session.Batman is None:
return aiohttp.web.HTTPUnauthorized()

Expand Down
2 changes: 1 addition & 1 deletion seacatauth/cookie/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ async def nginx(self, request):
"""
client_id = request.query.get("client_id")
if client_id is None:
raise ValueError("No 'client_id' parameter specified in anonymous introspection query.")
raise ValueError("No 'client_id' parameter specified in cookie introspection query.")

# TODO: Also check query for scope and validate it

Expand Down
2 changes: 1 addition & 1 deletion seacatauth/cookie/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ async def get_session_by_session_cookie_value(self, cookie_value):
Get session by cookie value.
"""
try:
session = await self.SessionService.get_by({SessionAdapter.FN.Cookie.Id: cookie_value})
session = await self.SessionService.get_by(SessionAdapter.FN.Cookie.Id, cookie_value)
except KeyError:
L.info("Session not found.", struct_data={"sci": cookie_value})
return None
Expand Down
2 changes: 1 addition & 1 deletion seacatauth/openidconnect/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ async def get_session_by_access_token(self, token_value):

# Locate the session
try:
session = await self.SessionService.get_by({SessionAdapter.FN.OAuth2.AccessToken: access_token})
session = await self.SessionService.get_by(SessionAdapter.FN.OAuth2.AccessToken, access_token)
except KeyError:
L.info("Session not found by access token: {}".format(access_token))
return None
Expand Down
1 change: 0 additions & 1 deletion seacatauth/openidconnect/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,3 @@ def oauth2_session_builder(oauth2_data):
if scope is None or "cookie" not in scope:
yield (SessionAdapter.FN.OAuth2.AccessToken, secrets.token_bytes(token_length))
yield (SessionAdapter.FN.OAuth2.RefreshToken, secrets.token_bytes(token_length))
yield (SessionAdapter.FN.OAuth2.IdToken, None)
18 changes: 12 additions & 6 deletions seacatauth/session/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,19 +144,25 @@ class Batman:
_prefix = "ba"
Token = "ba_t"

# Fields that are stored encrypted
SensitiveFields = frozenset([
FN.OAuth2.IdToken,
# Session identifiers are stored encrypted
# They are used as session lookup keys and need special encryption treatment for that
EncryptedIdentifierFields = frozenset([
FN.OAuth2.AccessToken,
FN.OAuth2.RefreshToken,
FN.Cookie.Id,
])

# Other sensitive fields (not used as lookup keys)
# They use regular encryption provided by asab.storage
EncryptedAttributes = frozenset([
FN.Batman.Token,
FN.OAuth2.IdToken,
])

EncryptedPrefix = b"$aescbc$"

def __init__(self, session_svc, session_dict):
self._decrypt_sensitive_fields(session_dict, session_svc)
self._decrypt_encrypted_identifiers(session_dict, session_svc)

self.Session = self._deserialize_session_data(session_dict)
self.Id = self.Session.Id
Expand Down Expand Up @@ -263,9 +269,9 @@ def rest_get(self):
session_dict = self.serialize()
return rest_get(session_dict)

def _decrypt_sensitive_fields(self, session_dict, session_svc):
def _decrypt_encrypted_identifiers(self, session_dict, session_svc):
# Decrypt sensitive fields
for field in self.SensitiveFields:
for field in self.EncryptedIdentifierFields:
# BACK COMPAT: Handle nested dictionaries
obj = session_dict
keys = field.split(".")
Expand Down
37 changes: 18 additions & 19 deletions seacatauth/session/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,9 +205,11 @@ async def create_session(
session_builders = list()
for session_builder in session_builders:
for key, value in session_builder:
if key in SessionAdapter.SensitiveFields and value is not None:
if key in SessionAdapter.EncryptedIdentifierFields and value is not None:
value = SessionAdapter.EncryptedPrefix + self.aes_encrypt(value)
upsertor.set(key, value)
upsertor.set(key, value)
else:
upsertor.set(key, value, encrypt=(key in SessionAdapter.EncryptedAttributes))

session_id = await upsertor.execute(event_type=EventTypes.SESSION_CREATED)

Expand All @@ -234,46 +236,43 @@ async def update_session(self, session_id: str, session_builders: list):

for session_builder in session_builders:
for key, value in session_builder:
upsertor.set(key, value)
upsertor.set(key, value, encrypt=(key in SessionAdapter.EncryptedAttributes))

await upsertor.execute(event_type=EventTypes.SESSION_UPDATED)

return await self.get(session_id)


async def get_by(self, criteria: dict):
async def get_by(self, key: str, value):
# Encrypt sensitive fields
query_filter = {}
for key, value in criteria.items():
if key in SessionAdapter.SensitiveFields:
query_filter[key] = SessionAdapter.EncryptedPrefix + self.aes_encrypt(value)
else:
query_filter[key] = value
if key in SessionAdapter.EncryptedIdentifierFields:
value = SessionAdapter.EncryptedPrefix + self.aes_encrypt(value)

collection = self.StorageService.Database[self.SessionCollection]
session_dict = await collection.find_one(query_filter)
session_dict = await self.StorageService.get_by(
self.SessionCollection, key, value, decrypt=SessionAdapter.EncryptedAttributes)
if session_dict is None:
raise exceptions.SessionNotFoundError("Session not found in database.", query=criteria)
raise exceptions.SessionNotFoundError("Session not found in database.", query={key: value})

# Do not return expired sessions
if session_dict[SessionAdapter.FN.Session.Expiration] < datetime.datetime.now(datetime.timezone.utc):
raise exceptions.SessionNotFoundError("Session expired.", query=criteria)
raise exceptions.SessionNotFoundError("Session expired.", query={key: value})

try:
session = SessionAdapter(self, session_dict)
except Exception as e:
L.error("Failed to create SessionAdapter from database object", struct_data={
L.exception("Failed to create SessionAdapter from database object.", struct_data={
"sid": session_dict.get("_id"),
})
raise exceptions.SessionNotFoundError("Session not found in database.", query=criteria) from e
raise exceptions.SessionNotFoundError("Session deserialization failed.", query={key: value}) from e

return session


async def get(self, session_id):
if isinstance(session_id, str):
session_id = bson.ObjectId(session_id)
session_dict = await self.StorageService.get(self.SessionCollection, session_id)
session_dict = await self.StorageService.get(
self.SessionCollection, session_id, decrypt=SessionAdapter.EncryptedAttributes)

# Do not return expired sessions
if session_dict[SessionAdapter.FN.Session.Expiration] < datetime.datetime.now(datetime.timezone.utc):
Expand All @@ -282,10 +281,10 @@ async def get(self, session_id):
try:
session = SessionAdapter(self, session_dict)
except Exception as e:
L.exception("Failed to create SessionAdapter from database object", struct_data={
L.exception("Failed to create SessionAdapter from database object.", struct_data={
"sid": session_dict.get("_id"),
})
raise exceptions.SessionNotFoundError("Session not found in database.", session_id=session_id) from e
raise exceptions.SessionNotFoundError("Session deserialization failed.", session_id=session_id) from e
return session


Expand Down

0 comments on commit 64eab67

Please sign in to comment.