touchque-authenticator 4.2.0__py3-none-any.whl
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.
- touchque/__init__.py +148 -0
- touchque/agent.py +314 -0
- touchque/agent_card.py +190 -0
- touchque/agent_frameworks.py +215 -0
- touchque/approval_proof.py +104 -0
- touchque/approval_token.py +134 -0
- touchque/client.py +105 -0
- touchque/code_reply.py +75 -0
- touchque/config.py +56 -0
- touchque/contrib/__init__.py +4 -0
- touchque/contrib/django.py +175 -0
- touchque/contrib/fastapi.py +208 -0
- touchque/contrib/flask.py +154 -0
- touchque/cross_app_access.py +222 -0
- touchque/exceptions.py +170 -0
- touchque/guard.py +290 -0
- touchque/hosted_login.py +474 -0
- touchque/http_client.py +156 -0
- touchque/mcp_auth.py +256 -0
- touchque/my_account.py +173 -0
- touchque/org_agent.py +262 -0
- touchque/rate_limiter.py +56 -0
- touchque/relay.py +643 -0
- touchque/resources/__init__.py +1 -0
- touchque/resources/action_types.py +52 -0
- touchque/resources/actions.py +234 -0
- touchque/resources/agents.py +236 -0
- touchque/resources/assist.py +115 -0
- touchque/resources/auth.py +65 -0
- touchque/resources/device_risk.py +174 -0
- touchque/resources/identity.py +268 -0
- touchque/resources/login.py +296 -0
- touchque/resources/mobile.py +130 -0
- touchque/resources/offline.py +138 -0
- touchque/resources/qr_pairings.py +93 -0
- touchque/resources/recovery.py +118 -0
- touchque/resources/risk.py +141 -0
- touchque/resources/safe_guard.py +99 -0
- touchque/resources/webauthn.py +115 -0
- touchque/resources/webhook.py +258 -0
- touchque/safe_guard.py +308 -0
- touchque/security_events.py +205 -0
- touchque/steps.py +239 -0
- touchque_authenticator-4.2.0.dist-info/METADATA +630 -0
- touchque_authenticator-4.2.0.dist-info/RECORD +48 -0
- touchque_authenticator-4.2.0.dist-info/WHEEL +5 -0
- touchque_authenticator-4.2.0.dist-info/licenses/LICENSE +21 -0
- touchque_authenticator-4.2.0.dist-info/top_level.txt +1 -0
touchque/org_agent.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""An organization's AI agent on TouchQue SSO (Organization → AI agents in the
|
|
2
|
+
dashboard): it gets 5-minute tokens for one of the organization's apps,
|
|
3
|
+
proving who it is with its own key (private_key_jwt) or with the token its
|
|
4
|
+
platform already gives it (GitHub Actions, GitLab, Google Cloud, Azure,
|
|
5
|
+
Kubernetes), and binds each token to a DPoP key made when it starts. The app
|
|
6
|
+
checks the token with TouchQue's JWKS and the DPoP proof on every call.
|
|
7
|
+
|
|
8
|
+
agent = TouchQueOrgAgent(agent_id='oagt_...', private_key=os.environ['TQ_ORG_AGENT_KEY'])
|
|
9
|
+
response = agent.request('GET', 'https://payments.acme.com/invoices', resource='tqapp_...')
|
|
10
|
+
|
|
11
|
+
# In a GitHub Actions job (permissions: id-token: write), with no key to store:
|
|
12
|
+
agent = TouchQueOrgAgent(agent_id='oagt_...', assertion=github_actions_token('touchque'))
|
|
13
|
+
|
|
14
|
+
Acting for a member, when its admin allowed it (Organization → AI agents → Access):
|
|
15
|
+
|
|
16
|
+
token = agent.exchange_token(subject_token=members_access_token, resource='tqapp_...') # RFC 8693
|
|
17
|
+
ask = agent.ask_member('ada@acme.com', binding_message='Pay invoice 42', authorization_details=[payment])
|
|
18
|
+
reply(f"Type {ask['binding_code']} in the TouchQue app.")
|
|
19
|
+
approved = agent.wait_for_member(ask) # CIBA, poll mode
|
|
20
|
+
|
|
21
|
+
The token vault: a member's own account at another service they connected on TouchQue, a fresh
|
|
22
|
+
provider token (never the refresh token)::
|
|
23
|
+
|
|
24
|
+
google = agent.get_connection_token(members_access_token, 'google')
|
|
25
|
+
|
|
26
|
+
A shared account (a site with only a password): the member's own TouchQue extension signs in, in their
|
|
27
|
+
browser, after their phone approves with the agent named; the agent never sees the password::
|
|
28
|
+
|
|
29
|
+
ask = agent.ask_to_open_shared_account('k3J...', 'ada@acme.com', message='Post the launch photo')
|
|
30
|
+
reply(f"Open {ask['open_url']} in your browser to let me in.")
|
|
31
|
+
result = agent.wait_for_shared_account(ask) # status: opened | refused | declined | expired
|
|
32
|
+
|
|
33
|
+
Needs the optional ``cryptography`` package: ``pip install "touchque-authenticator[agent]"``.
|
|
34
|
+
"""
|
|
35
|
+
import hashlib
|
|
36
|
+
import os
|
|
37
|
+
import re
|
|
38
|
+
import threading
|
|
39
|
+
import time
|
|
40
|
+
import uuid
|
|
41
|
+
from typing import Any, Callable, Dict, Optional, Tuple, Union
|
|
42
|
+
from urllib.parse import urlparse
|
|
43
|
+
|
|
44
|
+
import requests
|
|
45
|
+
|
|
46
|
+
from .agent import AGENT_ASSERTION_TYPE, TouchQueAgent, _b64url, _crypto, _json, _load_private_key, _public_jwk, jwk_thumbprint
|
|
47
|
+
from .exceptions import TouchQueAPIException, TouchQueConfigException, TouchQueNetworkException, TouchQueTimeoutException
|
|
48
|
+
|
|
49
|
+
__all__ = ['TouchQueOrgAgent', 'github_actions_token', 'file_token']
|
|
50
|
+
|
|
51
|
+
_ORG_AGENT_ID = re.compile(r'^oagt_[a-z2-9]{20}$')
|
|
52
|
+
_LOCAL_HOSTS = {'localhost', '127.0.0.1', '::1'}
|
|
53
|
+
_CIBA_GRANT = 'urn:openid:params:grant-type:ciba'
|
|
54
|
+
_TOKEN_EXCHANGE_GRANT = 'urn:ietf:params:oauth:grant-type:token-exchange'
|
|
55
|
+
_ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token'
|
|
56
|
+
_CONNECTION_TOKEN_TYPE = 'urn:touchque:params:oauth:token-type:connection-access-token'
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def github_actions_token(audience: str = 'touchque') -> Callable[[], str]:
|
|
60
|
+
"""The token a GitHub Actions job may ask for (the job needs ``permissions: id-token: write``)."""
|
|
61
|
+
def fetch() -> str:
|
|
62
|
+
url = os.environ.get('ACTIONS_ID_TOKEN_REQUEST_URL')
|
|
63
|
+
bearer = os.environ.get('ACTIONS_ID_TOKEN_REQUEST_TOKEN')
|
|
64
|
+
if not url or not bearer:
|
|
65
|
+
raise TouchQueConfigException('Not in a GitHub Actions job with `permissions: id-token: write`')
|
|
66
|
+
response = requests.get(url, params={'audience': audience}, headers={'Authorization': f'Bearer {bearer}'}, timeout=10)
|
|
67
|
+
value = response.json().get('value') if response.ok else None
|
|
68
|
+
if not value:
|
|
69
|
+
raise TouchQueConfigException('GitHub gave no token')
|
|
70
|
+
return value
|
|
71
|
+
return fetch
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def file_token(path: str) -> Callable[[], str]:
|
|
75
|
+
"""A token the platform writes to a file and renews (a Kubernetes projected service account token...)."""
|
|
76
|
+
def read() -> str:
|
|
77
|
+
with open(path, encoding='utf-8') as f:
|
|
78
|
+
return f.read().strip()
|
|
79
|
+
return read
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TouchQueOrgAgent:
|
|
83
|
+
"""An organization's AI agent (see the module docstring)."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, agent_id: Optional[str] = None, private_key: Union[Dict[str, Any], str, Any, None] = None,
|
|
86
|
+
kid: Optional[str] = None, assertion: Optional[Callable[[], str]] = None, issuer: Optional[str] = None,
|
|
87
|
+
resource: Optional[str] = None, scope: Optional[str] = None, timeout: int = 10000):
|
|
88
|
+
agent_id = agent_id if agent_id is not None else os.environ.get('TQ_ORG_AGENT_ID', '')
|
|
89
|
+
if not _ORG_AGENT_ID.match(agent_id or ''):
|
|
90
|
+
raise TouchQueConfigException('Set TQ_ORG_AGENT_ID (or pass agent_id): the oagt_... id from Organization → AI agents')
|
|
91
|
+
self.agent_id = agent_id
|
|
92
|
+
self.issuer = (issuer or os.environ.get('TQ_SSO_URL') or 'https://sso.touchque.com').rstrip('/')
|
|
93
|
+
parsed = urlparse(self.issuer)
|
|
94
|
+
if parsed.scheme != 'https' and not (parsed.scheme == 'http' and (parsed.hostname or '') in _LOCAL_HOSTS):
|
|
95
|
+
raise TouchQueConfigException(f'Refusing a plaintext issuer for "{parsed.hostname}": use https://')
|
|
96
|
+
self._assertion_source = assertion
|
|
97
|
+
if assertion is None:
|
|
98
|
+
key_input = private_key if private_key is not None else os.environ.get('TQ_ORG_AGENT_KEY')
|
|
99
|
+
if not key_input:
|
|
100
|
+
raise TouchQueConfigException("Pass private_key (or set TQ_ORG_AGENT_KEY), or assertion with the platform's token")
|
|
101
|
+
self._key = _load_private_key(key_input)
|
|
102
|
+
self._key_alg = 'EdDSA' if type(self._key).__name__.startswith('Ed25519') else 'ES256'
|
|
103
|
+
given = key_input.get('kid') if isinstance(key_input, dict) else None
|
|
104
|
+
self._kid = kid or given or jwk_thumbprint(_public_jwk(self._key))
|
|
105
|
+
hashes, serialization, ec, ed25519, _ = _crypto()
|
|
106
|
+
# The DPoP key: made here, never leaves this process.
|
|
107
|
+
self._dpop_key = ec.generate_private_key(ec.SECP256R1())
|
|
108
|
+
self._dpop_jwk = _public_jwk(self._dpop_key)
|
|
109
|
+
self._resource = resource
|
|
110
|
+
self._scope = scope
|
|
111
|
+
self._timeout = timeout / 1000.0
|
|
112
|
+
self._tokens: Dict[Tuple[str, str], Dict[str, Any]] = {}
|
|
113
|
+
self._lock = threading.Lock()
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _jws(alg: str, key: Any, header: Dict[str, Any], payload: Dict[str, Any]) -> str:
|
|
117
|
+
hashes, serialization, ec, ed25519, decode_dss_signature = _crypto()
|
|
118
|
+
signing_input = f"{_b64url(_json(dict(header, alg=alg)).encode())}.{_b64url(_json(payload).encode())}"
|
|
119
|
+
if alg == 'EdDSA':
|
|
120
|
+
sig = key.sign(signing_input.encode('ascii'))
|
|
121
|
+
else:
|
|
122
|
+
r, s = decode_dss_signature(key.sign(signing_input.encode('ascii'), ec.ECDSA(hashes.SHA256())))
|
|
123
|
+
sig = r.to_bytes(32, 'big') + s.to_bytes(32, 'big')
|
|
124
|
+
return f'{signing_input}.{_b64url(sig)}'
|
|
125
|
+
|
|
126
|
+
def dpop_proof(self, method: str, url: str, access_token: Optional[str] = None) -> str:
|
|
127
|
+
"""A DPoP proof (RFC 9449) for one request: with the token, for a call to the app."""
|
|
128
|
+
u = urlparse(url)
|
|
129
|
+
claims: Dict[str, Any] = {'htm': method.upper(), 'htu': f'{u.scheme}://{u.netloc}{u.path}', 'iat': int(time.time()), 'jti': str(uuid.uuid4())}
|
|
130
|
+
if access_token:
|
|
131
|
+
claims['ath'] = _b64url(hashlib.sha256(access_token.encode('ascii')).digest())
|
|
132
|
+
return self._jws('ES256', self._dpop_key, {'typ': 'dpop+jwt', 'jwk': self._dpop_jwk}, claims)
|
|
133
|
+
|
|
134
|
+
def _assertion(self, token_url: str) -> str:
|
|
135
|
+
if self._assertion_source is not None:
|
|
136
|
+
return self._assertion_source()
|
|
137
|
+
now = int(time.time())
|
|
138
|
+
return self._jws(self._key_alg, self._key, {'typ': 'JWT', 'kid': self._kid},
|
|
139
|
+
{'iss': self.agent_id, 'sub': self.agent_id, 'aud': token_url, 'iat': now, 'exp': now + 60, 'jti': str(uuid.uuid4())})
|
|
140
|
+
|
|
141
|
+
def _post(self, path: str, fields: Dict[str, Any], dpop: bool) -> Dict[str, Any]:
|
|
142
|
+
url = f'{self.issuer}{path}'
|
|
143
|
+
form = {'client_id': self.agent_id, 'client_assertion_type': AGENT_ASSERTION_TYPE, 'client_assertion': self._assertion(f'{self.issuer}/sso/token')}
|
|
144
|
+
form.update({k: v for k, v in fields.items() if v not in (None, '')})
|
|
145
|
+
try:
|
|
146
|
+
response = requests.post(url, data=form, headers={'DPoP': self.dpop_proof('POST', url)} if dpop else {}, timeout=self._timeout, allow_redirects=False)
|
|
147
|
+
except requests.exceptions.RequestException as e:
|
|
148
|
+
raise TouchQueNetworkException(str(e)) from e
|
|
149
|
+
if response.status_code != 200:
|
|
150
|
+
raise TouchQueAgent._error(response)
|
|
151
|
+
return response.json()
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def _token_of(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
155
|
+
token = {'access_token': data['access_token'], 'token_type': 'DPoP', 'expires_in': data.get('expires_in', 300), 'scope': data.get('scope', '')}
|
|
156
|
+
if isinstance(data.get('authorization_details'), list):
|
|
157
|
+
token['authorization_details'] = data['authorization_details']
|
|
158
|
+
return token
|
|
159
|
+
|
|
160
|
+
def get_token(self, resource: Optional[str] = None, scope: Optional[str] = None) -> Dict[str, Any]:
|
|
161
|
+
"""A token for one app (renewed 30 seconds before it expires):
|
|
162
|
+
``{'access_token', 'token_type': 'DPoP', 'expires_in', 'scope'}``."""
|
|
163
|
+
resource = resource or self._resource
|
|
164
|
+
scope = scope or self._scope
|
|
165
|
+
cache_key = (resource or '', scope or '')
|
|
166
|
+
with self._lock:
|
|
167
|
+
hit = self._tokens.get(cache_key)
|
|
168
|
+
if hit and hit['expires_at'] - time.time() > 30:
|
|
169
|
+
return hit['token']
|
|
170
|
+
token = self._token_of(self._post('/sso/token', {'grant_type': 'client_credentials', 'resource': resource, 'scope': scope}, True))
|
|
171
|
+
self._tokens[cache_key] = {'token': token, 'expires_at': time.time() + token['expires_in']}
|
|
172
|
+
return token
|
|
173
|
+
|
|
174
|
+
def exchange_token(self, subject_token: str, resource: Optional[str] = None, scope: Optional[str] = None) -> Dict[str, Any]:
|
|
175
|
+
"""A token for a member, with their own access token for one of the
|
|
176
|
+
agent's apps (RFC 8693 token exchange): sub is the member, act the agent."""
|
|
177
|
+
return self._token_of(self._post('/sso/token', {
|
|
178
|
+
'grant_type': _TOKEN_EXCHANGE_GRANT, 'subject_token': subject_token, 'subject_token_type': _ACCESS_TOKEN_TYPE,
|
|
179
|
+
'resource': resource or self._resource, 'scope': scope or self._scope,
|
|
180
|
+
}, True))
|
|
181
|
+
|
|
182
|
+
def get_connection_token(self, subject_token: str, connection: str, scope: Optional[str] = None) -> Dict[str, Any]:
|
|
183
|
+
"""The token vault: a member's own access token at another service (Google, Microsoft 365,
|
|
184
|
+
GitHub...) they connected on TouchQue, for a connection that names this agent:
|
|
185
|
+
``{'access_token', 'token_type': 'Bearer', 'expires_in'?, 'scope', 'connection'}``.
|
|
186
|
+
``subject_token`` is the member's token, as for ``exchange_token``; ``scope`` may only narrow
|
|
187
|
+
what the member granted. TouchQue refreshes it; the refresh token never leaves.
|
|
188
|
+
TouchQueAPIError invalid_grant: the member has not connected it (or disconnected, or left)."""
|
|
189
|
+
data = self._post('/sso/token', {
|
|
190
|
+
'grant_type': _TOKEN_EXCHANGE_GRANT, 'subject_token': subject_token, 'subject_token_type': _ACCESS_TOKEN_TYPE,
|
|
191
|
+
'requested_token_type': _CONNECTION_TOKEN_TYPE, 'connection': connection, 'scope': scope,
|
|
192
|
+
}, True)
|
|
193
|
+
out = {'access_token': data['access_token'], 'token_type': 'Bearer', 'scope': data.get('scope') or '', 'connection': data.get('connection') or connection}
|
|
194
|
+
if isinstance(data.get('expires_in'), int):
|
|
195
|
+
out['expires_in'] = data['expires_in']
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
def ask_to_open_shared_account(self, account: str, login_hint: str, message: Optional[str] = None) -> Dict[str, Any]:
|
|
199
|
+
"""Asks to open a shared account (its key, from Organization -> AI agents -> Access) for a member:
|
|
200
|
+
``{'ask_id', 'open_url', 'status', 'expires_in'}``. The member opens ``open_url`` in the browser
|
|
201
|
+
where their TouchQue extension is paired; their phone approves with this agent named and the
|
|
202
|
+
extension signs in there. The agent never sees the password. ``message`` (at most 64
|
|
203
|
+
characters) is shown to the member marked unverified."""
|
|
204
|
+
return self._post('/sso/agents/shared-accounts/asks', {'account': account, 'login_hint': login_hint, 'binding_message': message}, False)
|
|
205
|
+
|
|
206
|
+
def shared_account_ask_status(self, ask_id: str) -> Dict[str, Any]:
|
|
207
|
+
"""Where an ask stands: ``{'status', 'reason'?, 'case_code'?}``; only whether it opened, never
|
|
208
|
+
anything of the account."""
|
|
209
|
+
return self._post(f'/sso/agents/shared-accounts/asks/{ask_id}', {}, False)
|
|
210
|
+
|
|
211
|
+
def wait_for_shared_account(self, ask: Dict[str, Any], interval: float = 3.0) -> Dict[str, Any]:
|
|
212
|
+
"""Polls an ask until it ends: opened, refused, declined or expired."""
|
|
213
|
+
deadline = time.time() + int(ask.get('expires_in') or 600) + 5
|
|
214
|
+
while True:
|
|
215
|
+
s = self.shared_account_ask_status(ask['ask_id'])
|
|
216
|
+
if s.get('status') not in ('waiting_member', 'asked_phone') or time.time() > deadline:
|
|
217
|
+
return s
|
|
218
|
+
time.sleep(interval)
|
|
219
|
+
|
|
220
|
+
def ask_member(self, login_hint: str, binding_message: Optional[str] = None, authorization_details: Optional[list] = None,
|
|
221
|
+
resource: Optional[str] = None, scope: Optional[str] = None, requested_expiry: Optional[int] = None) -> Dict[str, Any]:
|
|
222
|
+
"""Asks a member to approve on their phone (CIBA): ``{'auth_req_id',
|
|
223
|
+
'expires_in', 'interval', 'binding_code'}``. Show binding_code in the
|
|
224
|
+
conversation, then ``wait_for_member``."""
|
|
225
|
+
data = self._post('/sso/bc-authorize', {
|
|
226
|
+
'login_hint': login_hint, 'binding_message': binding_message, 'resource': resource or self._resource, 'scope': scope or self._scope,
|
|
227
|
+
'authorization_details': _json(authorization_details) if authorization_details else None,
|
|
228
|
+
'requested_expiry': str(requested_expiry) if requested_expiry else None,
|
|
229
|
+
}, False)
|
|
230
|
+
return {'auth_req_id': data['auth_req_id'], 'expires_in': data.get('expires_in', 300), 'interval': data.get('interval', 5),
|
|
231
|
+
**({'binding_code': data['binding_code']} if data.get('binding_code') else {})}
|
|
232
|
+
|
|
233
|
+
def wait_for_member(self, request: Dict[str, Any]) -> Dict[str, Any]:
|
|
234
|
+
"""The member's answer (polling at the interval, slower when asked): the
|
|
235
|
+
token once they approve; TouchQueAPIException access_denied or
|
|
236
|
+
expired_token otherwise, TouchQueTimeoutException after it expired."""
|
|
237
|
+
interval = max(1, int(request.get('interval', 5)))
|
|
238
|
+
deadline = time.time() + request.get('expires_in', 300) + 5
|
|
239
|
+
while time.time() < deadline:
|
|
240
|
+
time.sleep(interval)
|
|
241
|
+
try:
|
|
242
|
+
return self._token_of(self._post('/sso/token', {'grant_type': _CIBA_GRANT, 'auth_req_id': request['auth_req_id']}, True))
|
|
243
|
+
except TouchQueAPIException as e:
|
|
244
|
+
if e.code == 'authorization_pending':
|
|
245
|
+
continue
|
|
246
|
+
if e.code == 'slow_down':
|
|
247
|
+
interval += 5
|
|
248
|
+
continue
|
|
249
|
+
raise
|
|
250
|
+
raise TouchQueTimeoutException('The member did not answer in time')
|
|
251
|
+
|
|
252
|
+
def authorization(self, method: str, url: str, resource: Optional[str] = None, scope: Optional[str] = None) -> Dict[str, str]:
|
|
253
|
+
"""The two headers for one call to the app: Authorization: DPoP <token> and its proof."""
|
|
254
|
+
token = self.get_token(resource, scope)['access_token']
|
|
255
|
+
return {'Authorization': f'DPoP {token}', 'DPoP': self.dpop_proof(method, url, token)}
|
|
256
|
+
|
|
257
|
+
def request(self, method: str, url: str, resource: Optional[str] = None, scope: Optional[str] = None, **kwargs) -> requests.Response:
|
|
258
|
+
"""``requests.request`` with the agent's token and a fresh proof."""
|
|
259
|
+
headers = dict(kwargs.pop('headers', None) or {})
|
|
260
|
+
headers.update(self.authorization(method, url, resource, scope))
|
|
261
|
+
kwargs.setdefault('timeout', self._timeout)
|
|
262
|
+
return requests.request(method, url, headers=headers, **kwargs)
|
touchque/rate_limiter.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A small in-memory fixed-window rate limiter for the relay handler's pre-auth
|
|
3
|
+
routes. It protects your own server (and your TouchQue quota) from scripted
|
|
4
|
+
abuse of routes anyone on the internet can call. It is per process: behind
|
|
5
|
+
several workers or instances each keeps its own counters, which still caps
|
|
6
|
+
abuse per process. Put your own shared limiter in front of the relay if you
|
|
7
|
+
need one global budget, and pass ``rate_limit=False``.
|
|
8
|
+
"""
|
|
9
|
+
import math
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from typing import Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
MAX_KEYS = 50000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FixedWindowLimiter:
|
|
18
|
+
"""Fixed-window counter keyed by an arbitrary string.
|
|
19
|
+
|
|
20
|
+
``window_ms``: window length in milliseconds (default 60000).
|
|
21
|
+
``per_ip``: requests per client IP per route per window (default 20).
|
|
22
|
+
``per_user``: requests per username per route per window (default 5).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, window_ms: Optional[int] = None, per_ip: Optional[int] = None,
|
|
26
|
+
per_user: Optional[int] = None):
|
|
27
|
+
self.window_ms = 60000 if window_ms is None else window_ms
|
|
28
|
+
self.per_ip = 20 if per_ip is None else per_ip
|
|
29
|
+
self.per_user = 5 if per_user is None else per_user
|
|
30
|
+
# key -> [count, reset_at_ms]; dicts keep insertion order, like a JS Map.
|
|
31
|
+
self._entries = {} # type: Dict[str, List[float]]
|
|
32
|
+
self._lock = threading.Lock()
|
|
33
|
+
|
|
34
|
+
def hit(self, key: str, limit: int, now: Optional[float] = None) -> int:
|
|
35
|
+
"""Counts one hit. Returns seconds to wait when over ``limit``, otherwise 0."""
|
|
36
|
+
if now is None:
|
|
37
|
+
now = time.time() * 1000
|
|
38
|
+
with self._lock:
|
|
39
|
+
e = self._entries.get(key)
|
|
40
|
+
if e is None or e[1] <= now:
|
|
41
|
+
if e is None and len(self._entries) >= MAX_KEYS:
|
|
42
|
+
self._prune(now)
|
|
43
|
+
e = [0, now + self.window_ms]
|
|
44
|
+
self._entries[key] = e
|
|
45
|
+
e[0] += 1
|
|
46
|
+
if e[0] > limit:
|
|
47
|
+
return max(1, int(math.ceil((e[1] - now) / 1000.0)))
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
def _prune(self, now: float) -> None:
|
|
51
|
+
for k in [k for k, v in self._entries.items() if v[1] <= now]:
|
|
52
|
+
del self._entries[k]
|
|
53
|
+
# Still full (a flood of distinct keys): drop the oldest half rather than grow without bound.
|
|
54
|
+
if len(self._entries) >= MAX_KEYS:
|
|
55
|
+
for k in list(self._entries.keys())[:MAX_KEYS // 2 + 1]:
|
|
56
|
+
del self._entries[k]
|