Skip to content

Commit 6dc0e18

Browse files
Merge pull request #394 from minos-framework/issue-390-kong-discovery-jwt-expiration
#390 - Kong Discovery JWT expiration
2 parents f98e0a9 + 4a901de commit 6dc0e18

File tree

10 files changed

+438
-20
lines changed

10 files changed

+438
-20
lines changed

packages/plugins/minos-discovery-kong/README.md

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,34 @@ services:
6363
- NODE_ENV=production
6464
```
6565
66+
## Decorators
67+
Decorator `@enroute` can support next params:
68+
- `path` - route url path.
69+
- `method` - HTTP method.
70+
- `authenticated` (Optional) - True if route need authentication.
71+
- `authorized_groups` (Optional) - Groups which can access to specified route (they must exist in Kong).
72+
- `regex_priority` (Optional) - A number used to choose which route resolves a given request when several routes match it using regexes simultaneously. When two routes match the path and have the same regex_priority, the older one (lowest created_at) is used. Note that the priority for non-regex routes is different (longer non-regex routes are matched before shorter ones). Defaults to 0.
73+
74+
Example:
75+
```python
76+
@enroute.rest.command(f"/users/{{uuid:{UUID_REGEX.pattern}}}/jwt", "POST", authenticated=True, authorized_groups=["admin"], regex_priority=2)
77+
@enroute.broker.command("GetUserJWT")
78+
async def foo(self, request: Request) -> Response:
79+
...
80+
```
81+
## Route path
82+
It is important to know that it is best to define routes with a regular expression when it is an id, uuid or similar. This is to avoid collisions with similar routes.
83+
Instead of using:
84+
```python
85+
@enroute.rest.command("/users/{uuid}", "POST")
86+
```
87+
Use:
88+
```python
89+
import re
90+
UUID_REGEX = re.compile(r"\w{8}-\w{4}-\w{4}-\w{4}-\w{12}")
91+
@enroute.rest.command(f"/users/{{uuid:{UUID_REGEX.pattern}}}", "POST")
92+
```
93+
6694
## Authentication
6795

6896
Modify `config.yml` file. Add new middleware and modify discovery section:
@@ -85,6 +113,91 @@ Currently, there are 2 possible types of authentication:
85113
- `basic-auth`
86114
- `jwt`
87115

116+
For jwt auth type you can specify default token expiration. Example:
117+
```yaml
118+
...
119+
middleware:
120+
...
121+
- minos.plugins.kong.middleware
122+
123+
discovery:
124+
connector: minos.networks.DiscoveryConnector
125+
client: minos.plugins.kong.KongDiscoveryClient
126+
host: localhost
127+
auth-type: jwt
128+
token-exp: 60 # seconds
129+
port: 8001
130+
...
131+
```
132+
133+
### JWT Token creation & refresh
134+
Example on how to create and refresh token. You need to store in database or similar the secret and key returned form kong in order to refresh existing token.
135+
```python
136+
from minos.common import (
137+
UUID_REGEX,
138+
NotProvidedException,
139+
Config,
140+
Inject,
141+
)
142+
from minos.cqrs import (
143+
CommandService,
144+
)
145+
from minos.networks import (
146+
Request,
147+
Response,
148+
enroute,
149+
)
150+
151+
from ..aggregates import (
152+
User,
153+
)
154+
from minos.plugins.kong import KongClient
155+
156+
class UserCommandService(CommandService):
157+
"""UserCommandService class."""
158+
159+
def __init__(self, *args, **kwargs):
160+
super().__init__(*args, **kwargs)
161+
self.kong = self._get_kong_client()
162+
163+
@staticmethod
164+
@Inject()
165+
def _get_kong_client(config: Config) -> KongClient:
166+
"""Get the service name."""
167+
if config is None:
168+
raise NotProvidedException("The config object must be provided.")
169+
return KongClient.from_config(config)
170+
171+
@enroute.rest.command(f"/users/{{uuid:{UUID_REGEX.pattern}}}/jwt", "POST", authenticated=True,
172+
authorized_groups=["admin"], regex_priority=3)
173+
@enroute.broker.command("GetUserJWT")
174+
async def create_user_jwt(self, request: Request) -> Response:
175+
params = await request.params()
176+
uuid = params["uuid"]
177+
user = await User.get(uuid)
178+
179+
if user.uuid == request.user:
180+
token = await self.add_jwt_to_consumer(request.headers.get("X-Consumer-ID"))
181+
return Response({"token": token})
182+
else:
183+
return Response(status=404)
184+
185+
async def add_jwt_to_consumer(self, consumer: str):
186+
resp = await self.kong.add_jwt_to_consumer(consumer=consumer)
187+
res = resp.json()
188+
self.key = res['key']
189+
self.secret = res['secret']
190+
token = await self.kong.generate_jwt_token(key=self.key, secret=self.secret)
191+
return token
192+
193+
@enroute.rest.command(f"/users/{{uuid:{UUID_REGEX.pattern}}}/refresh-jwt", "POST", authenticated=True,
194+
authorized_groups=["admin"], regex_priority=3)
195+
@enroute.broker.command("RefreshJWT")
196+
async def refresh_jwt(self, request: Request) -> Response:
197+
token = await self.kong.generate_jwt_token(key=self.key, secret=self.secret)
198+
return Response({"token": token})
199+
```
200+
88201
For the route to be authenticated with the method specified above, a parameter called `authenticated` must be passed:
89202
```python
90203
class CategoryCommandService(CommandService):
@@ -222,6 +335,8 @@ class UserCommandService(CommandService):
222335
```
223336

224337
You can get read the official docs [here](https://pantsel.github.io/konga/).
338+
339+
225340
## Documentation
226341

227342
The official API Reference is publicly available at the [GitHub Pages](https://minos-framework.github.io/minos-python).

packages/plugins/minos-discovery-kong/minos/plugins/kong/client.py

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,51 @@
1+
from __future__ import (
2+
annotations,
3+
)
4+
5+
from datetime import (
6+
datetime,
7+
timedelta,
8+
)
19
from uuid import (
210
UUID,
311
)
412

513
import httpx as httpx
14+
import jwt
615

16+
from minos.common import (
17+
Config,
18+
SetupMixin,
19+
current_datetime,
20+
)
721

8-
class KongClient:
22+
23+
class KongClient(SetupMixin):
924
"""Kong Client class."""
1025

11-
def __init__(self, route):
12-
self.route = route
26+
def __init__(
27+
self, protocol: str = "http", host: str = None, port: int = None, token_expiration_sec: int = None, **kwargs
28+
):
29+
super().__init__(**kwargs)
30+
if host is None:
31+
host = "localhost"
32+
if port is None:
33+
port = 8001
34+
if token_expiration_sec is None:
35+
token_expiration_sec = 60 * 5
36+
37+
self.route = f"{protocol}://{host}:{port}"
38+
self.token_expiration_sec = token_expiration_sec
39+
40+
@classmethod
41+
def _from_config(cls, config: Config, **kwargs) -> KongClient:
42+
discovery_config = config.get_discovery()
43+
44+
token_expiration_sec = discovery_config.get("token-exp")
45+
host = discovery_config.get("host")
46+
port = discovery_config.get("port")
47+
48+
return cls(host=host, port=port, token_expiration_sec=token_expiration_sec, **kwargs)
1349

1450
@staticmethod
1551
async def register_service(
@@ -49,6 +85,7 @@ async def create_route(
4985
methods: list[str],
5086
paths: list[str],
5187
service: str,
88+
regex_priority: int,
5289
strip_path: bool = False,
5390
):
5491
url = f"{endpoint}/routes"
@@ -57,6 +94,7 @@ async def create_route(
5794
"methods": methods,
5895
"paths": paths,
5996
"service": {"id": service},
97+
"regex_priority": regex_priority,
6098
"strip_path": strip_path,
6199
}
62100

@@ -130,8 +168,40 @@ async def activate_basic_auth_plugin_on_route(self, route_id: str):
130168
return resp
131169

132170
async def activate_jwt_plugin_on_route(self, route_id: str):
133-
payload = {"name": "jwt", "config": {"secret_is_base64": False, "run_on_preflight": True}}
171+
payload = {
172+
"name": "jwt",
173+
"config": {"secret_is_base64": False, "run_on_preflight": True, "claims_to_verify": ["exp", "nbf"]},
174+
}
134175

135176
async with httpx.AsyncClient() as client:
136177
resp = await client.post(f"{self.route}/routes/{route_id}/plugins", json=payload)
137178
return resp
179+
180+
async def generate_jwt_token(
181+
self, key: str, secret: str, algorithm: str = "HS256", exp: datetime = None, nbf: datetime = None
182+
) -> str:
183+
payload = {"iss": key, "exp": exp, "nbf": nbf}
184+
185+
current = current_datetime()
186+
187+
if not exp:
188+
payload["exp"] = current + timedelta(seconds=self.token_expiration_sec)
189+
190+
if not nbf:
191+
payload["nbf"] = current
192+
193+
return jwt.encode(payload, secret, algorithm=algorithm)
194+
195+
@staticmethod
196+
async def decode_token(token: str, algorithm: str = "HS256"):
197+
return jwt.decode(token, options={"verify_signature": False}, algorithms=[algorithm])
198+
199+
async def get_jwt_by_id(self, id: str):
200+
async with httpx.AsyncClient() as client:
201+
resp = await client.get(f"{self.route}/jwts/{id}")
202+
return resp
203+
204+
async def get_consumer_jwts(self, consumer: str):
205+
async with httpx.AsyncClient() as client:
206+
resp = await client.get(f"{self.route}/consumers/{consumer}/jwt")
207+
return resp

packages/plugins/minos-discovery-kong/minos/plugins/kong/discovery.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def __init__(
4141
host: Optional[str] = None,
4242
port: Optional[int] = None,
4343
circuit_breaker_exceptions: Iterable[type] = tuple(),
44+
client: KongClient = None,
4445
**kwargs,
4546
):
4647
if host is None:
@@ -50,7 +51,11 @@ def __init__(
5051
super().__init__(
5152
host, port, circuit_breaker_exceptions=(httpx.HTTPStatusError, *circuit_breaker_exceptions), **kwargs
5253
)
53-
self.kong = KongClient(self.route)
54+
55+
if client is None:
56+
client = KongClient(host=host, port=port)
57+
58+
self.client = client
5459

5560
self.auth_type = None
5661
if "auth_type" in kwargs:
@@ -66,8 +71,9 @@ def _from_config(cls, config: Config, **kwargs) -> KongDiscoveryClient:
6671
auth_type = config.get_by_key("discovery.auth-type")
6772
except Exception:
6873
auth_type = None
74+
client = KongClient.from_config(config)
6975

70-
return super()._from_config(config, auth_type=auth_type, **kwargs)
76+
return super()._from_config(config, auth_type=auth_type, client=client, **kwargs)
7177

7278
async def subscribe(
7379
self, host: str, port: int, name: str, endpoints: list[dict[str, str]], *args, **kwargs
@@ -83,43 +89,48 @@ async def subscribe(
8389
:return: This method does not return anything.
8490
"""
8591

86-
fnsr = partial(self.kong.register_service, self.route, name, host, port)
92+
fnsr = partial(self.client.register_service, self.route, name, host, port)
8793
response_service = await self.with_circuit_breaker(fnsr) # register a service
8894
if response_service.status_code == 409: # service already exist
8995
# if service already exist, delete and add again
90-
fn_delete = partial(self.kong.delete_service, self.route, name)
96+
fn_delete = partial(self.client.delete_service, self.route, name)
9197
await self.with_circuit_breaker(fn_delete) # delete the service
92-
fnsr = partial(self.kong.register_service, self.route, name, host, port)
98+
fnsr = partial(self.client.register_service, self.route, name, host, port)
9399
response_service = await self.with_circuit_breaker(fnsr) # send the servie subscription again
94100

95101
content_service = response_service.json() # get the servie information like the id
96102

97103
for endpoint in endpoints:
98104
endpointClass = Endpoint(endpoint["url"])
99105

106+
regex_priority = 0
107+
if "regex_priority" in endpoint:
108+
regex_priority = endpoint["regex_priority"]
109+
100110
fn = partial(
101-
self.kong.create_route,
111+
self.client.create_route,
102112
self.route,
103113
["http"],
104114
[endpoint["method"]],
105-
[endpointClass.path_as_str],
115+
[endpointClass.path_as_regex],
106116
content_service["id"],
117+
regex_priority,
107118
False,
108119
)
109120
response = await self.with_circuit_breaker(fn) # send the route request
110121
resp = response.json()
111122

112123
if "authenticated" in endpoint and self.auth_type:
113124
if self.auth_type == "basic-auth":
114-
fn = partial(self.kong.activate_basic_auth_plugin_on_route, route_id=resp["id"])
125+
fn = partial(self.client.activate_basic_auth_plugin_on_route, route_id=resp["id"])
115126
await self.with_circuit_breaker(fn)
116127
elif self.auth_type == "jwt":
117-
fn = partial(self.kong.activate_jwt_plugin_on_route, route_id=resp["id"])
128+
fn = partial(self.client.activate_jwt_plugin_on_route, route_id=resp["id"])
118129
await self.with_circuit_breaker(fn)
119130

120131
if "authorized_groups" in endpoint:
121132
fn = partial(
122-
self.kong.activate_acl_plugin_on_route, route_id=resp["id"], allow=endpoint["authorized_groups"]
133+
self.client.activate_acl_plugin_on_route, route_id=resp["id"], allow=endpoint["authorized_groups"]
123134
)
124135
await self.with_circuit_breaker(fn)
125136

@@ -133,6 +144,6 @@ async def unsubscribe(self, name: str, *args, **kwargs) -> httpx.Response:
133144
:param kwargs: Additional named arguments.
134145
:return: This method does not return anything.
135146
"""
136-
fn = partial(self.kong.delete_service, self.route, name)
147+
fn = partial(self.client.delete_service, self.route, name)
137148
response = await self.with_circuit_breaker(fn)
138149
return response

packages/plugins/minos-discovery-kong/minos/plugins/kong/utils.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1+
import re
2+
3+
14
class PathPart:
25
def __init__(self, name: str):
36
self.name = name
47
self.is_generic: bool = True if self.name.startswith("{") and self.name.endswith("}") else False
58

69

710
class Endpoint:
11+
part_path_pattern = r"\{\S+:.+\}"
12+
813
def __init__(self, path: str):
914
self.path: tuple[PathPart] = tuple(PathPart(path_part) for path_part in path.split("/"))
1015

@@ -16,3 +21,18 @@ def path_as_str(self) -> str:
1621
part.name = ".*"
1722
list_parts.append(str(part.name))
1823
return "/".join(list_parts)
24+
25+
@property
26+
def path_as_regex(self) -> str:
27+
list_parts = []
28+
for part in self.path:
29+
if part.is_generic:
30+
if re.match(self.part_path_pattern, part.name):
31+
regex = part.name.split(":")[1]
32+
regex = regex[:-1]
33+
list_parts.append(regex)
34+
else:
35+
list_parts.append(".*")
36+
else:
37+
list_parts.append(part.name)
38+
return "/".join(list_parts)

0 commit comments

Comments
 (0)