nc-user-terminator 0.1.3__py3-none-any.whl → 0.1.5__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.
- nc_user_manager/cache.py +88 -0
- nc_user_manager/client.py +116 -7
- nc_user_manager/utils.py +45 -7
- {nc_user_terminator-0.1.3.dist-info → nc_user_terminator-0.1.5.dist-info}/METADATA +6 -2
- nc_user_terminator-0.1.5.dist-info/RECORD +10 -0
- nc_user_terminator-0.1.3.dist-info/RECORD +0 -9
- {nc_user_terminator-0.1.3.dist-info → nc_user_terminator-0.1.5.dist-info}/WHEEL +0 -0
- {nc_user_terminator-0.1.3.dist-info → nc_user_terminator-0.1.5.dist-info}/top_level.txt +0 -0
nc_user_manager/cache.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
from cachetools import TTLCache
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
DEFAULT_TTL = 3600
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseCache:
|
|
10
|
+
"""缓存接口"""
|
|
11
|
+
is_async = False
|
|
12
|
+
|
|
13
|
+
def set(self, key: str, value: Any, ttl: Optional[int] = None):
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
|
|
16
|
+
def get(self, key: str) -> Any:
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
|
|
19
|
+
def delete(self, key: str):
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------
|
|
24
|
+
# 内存缓存
|
|
25
|
+
# ---------------------
|
|
26
|
+
class MemoryCache(BaseCache):
|
|
27
|
+
is_async = False
|
|
28
|
+
|
|
29
|
+
def __init__(self, maxsize: int = 1000, ttl: int = DEFAULT_TTL):
|
|
30
|
+
self._cache = TTLCache(maxsize=maxsize, ttl=ttl)
|
|
31
|
+
|
|
32
|
+
def set(self, key: str, value: Any, ttl: Optional[int] = None):
|
|
33
|
+
# cachetools TTLCache 不支持 per-key TTL, 所以只使用全局 TTL
|
|
34
|
+
self._cache[key] = value
|
|
35
|
+
|
|
36
|
+
def get(self, key: str) -> Any:
|
|
37
|
+
return self._cache.get(key)
|
|
38
|
+
|
|
39
|
+
def delete(self, key: str):
|
|
40
|
+
self._cache.pop(key, None)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------
|
|
44
|
+
# Redis 缓存
|
|
45
|
+
# ---------------------
|
|
46
|
+
class RedisCache(BaseCache):
|
|
47
|
+
"""同步 Redis"""
|
|
48
|
+
is_async = False
|
|
49
|
+
|
|
50
|
+
def __init__(self, redis_client, ttl: int = DEFAULT_TTL):
|
|
51
|
+
self.redis = redis_client
|
|
52
|
+
self.ttl = ttl
|
|
53
|
+
|
|
54
|
+
def set(self, key: str, value: Any, ttl: Optional[int] = None):
|
|
55
|
+
ttl = ttl or self.ttl
|
|
56
|
+
self.redis.setex(key, ttl, json.dumps(value))
|
|
57
|
+
|
|
58
|
+
def get(self, key: str) -> Any:
|
|
59
|
+
data = self.redis.get(key)
|
|
60
|
+
if data:
|
|
61
|
+
return json.loads(data)
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
def delete(self, key: str):
|
|
65
|
+
self.redis.delete(key)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AsyncRedisCache(BaseCache):
|
|
69
|
+
"""异步 Redis"""
|
|
70
|
+
is_async = True
|
|
71
|
+
|
|
72
|
+
def __init__(self, redis_client, ttl: int = DEFAULT_TTL):
|
|
73
|
+
self.redis = redis_client
|
|
74
|
+
self.ttl = ttl
|
|
75
|
+
|
|
76
|
+
async def set(self, key: str, value: Any, ttl: Optional[int] = None):
|
|
77
|
+
ttl = ttl or self.ttl
|
|
78
|
+
await self.redis.setex(key, ttl, json.dumps(value))
|
|
79
|
+
|
|
80
|
+
async def get(self, key: str) -> Any:
|
|
81
|
+
data = await self.redis.get(key)
|
|
82
|
+
if data:
|
|
83
|
+
return json.loads(data)
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
async def delete(self, key: str):
|
|
87
|
+
await self.redis.delete(key)
|
|
88
|
+
|
nc_user_manager/client.py
CHANGED
|
@@ -1,34 +1,40 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
from typing import Optional, Dict, Any
|
|
3
3
|
|
|
4
|
+
from cache import MemoryCache, BaseCache, AsyncRedisCache
|
|
4
5
|
from models import UserResponse, OAuth2AuthorizeResponse, CallbackResponse
|
|
5
6
|
from exceptions import OAuthError
|
|
6
7
|
from utils import request
|
|
7
8
|
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
PRODUCT_HEADER = "X-Product-Code"
|
|
11
|
+
DEFAULT_TTL = 3600
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
class OAuthClient:
|
|
13
15
|
def __init__(
|
|
14
16
|
self,
|
|
15
17
|
base_url: str,
|
|
16
|
-
|
|
18
|
+
product_code: str,
|
|
17
19
|
redirect_url: Optional[str] = None,
|
|
18
20
|
single_session: bool = False,
|
|
21
|
+
cache: Optional[BaseCache] = None,
|
|
19
22
|
):
|
|
20
23
|
"""
|
|
21
24
|
OAuth 客户端
|
|
22
25
|
|
|
23
26
|
:param base_url: 服务端基础地址 (例如 http://localhost:8000)
|
|
24
|
-
:param
|
|
27
|
+
:param product_code: 产品编码
|
|
25
28
|
:param redirect_url: 可选,重定向地址
|
|
26
29
|
:param single_session: 是否单会话登录
|
|
27
30
|
"""
|
|
28
31
|
self._base_url = base_url.rstrip("/")
|
|
29
|
-
self.
|
|
32
|
+
self._product_code = product_code
|
|
30
33
|
self._redirect_url = redirect_url
|
|
31
34
|
self._single_session = single_session
|
|
35
|
+
self._cache = cache or MemoryCache(maxsize=10000, ttl=DEFAULT_TTL)
|
|
36
|
+
# 异步缓存,仅允许异步 redis
|
|
37
|
+
self._async_cache = self._cache.is_async
|
|
32
38
|
|
|
33
39
|
# ----------------------
|
|
34
40
|
# 内部异步包装
|
|
@@ -37,7 +43,7 @@ class OAuthClient:
|
|
|
37
43
|
return await asyncio.to_thread(request, *args, **kwargs)
|
|
38
44
|
|
|
39
45
|
def _headers(self, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
|
40
|
-
headers = {
|
|
46
|
+
headers = {PRODUCT_HEADER: self._product_code}
|
|
41
47
|
if extra:
|
|
42
48
|
headers.update(extra)
|
|
43
49
|
return headers
|
|
@@ -143,19 +149,85 @@ class OAuthClient:
|
|
|
143
149
|
)
|
|
144
150
|
return CallbackResponse(res_dict)
|
|
145
151
|
|
|
152
|
+
# ----------------------
|
|
153
|
+
# 缓存工具
|
|
154
|
+
# ----------------------
|
|
155
|
+
def _cache_user(self, token: str, user_dict: dict, ttl=DEFAULT_TTL):
|
|
156
|
+
key = f"user:{token}"
|
|
157
|
+
self._cache.set(key, user_dict, ttl)
|
|
158
|
+
|
|
159
|
+
if self._single_session:
|
|
160
|
+
user_id = user_dict.get("id")
|
|
161
|
+
if user_id:
|
|
162
|
+
old_token = self._cache.get(f"user_token:{user_id}")
|
|
163
|
+
if old_token and old_token != token:
|
|
164
|
+
self._cache.delete(f"user:{old_token}")
|
|
165
|
+
self._cache.set(f"user_token:{user_id}", token, ttl)
|
|
166
|
+
|
|
167
|
+
async def _cache_user_async(self, token: str, user_dict: dict, ttl=DEFAULT_TTL):
|
|
168
|
+
if not self._async_cache:
|
|
169
|
+
raise RuntimeError("异步缓存未配置")
|
|
170
|
+
|
|
171
|
+
key = f"user:{token}"
|
|
172
|
+
await self._cache.set(key, user_dict, ttl)
|
|
173
|
+
|
|
174
|
+
if self._single_session:
|
|
175
|
+
user_id = user_dict.get("id")
|
|
176
|
+
if user_id:
|
|
177
|
+
old_token = await self._cache.get(f"user_token:{user_id}")
|
|
178
|
+
if old_token and old_token != token:
|
|
179
|
+
await self._cache.delete(f"user:{old_token}")
|
|
180
|
+
await self._cache.set(f"user_token:{user_id}", token, ttl)
|
|
181
|
+
|
|
182
|
+
def _uncache_user(self, token: str, user_dict: Optional[dict] = None):
|
|
183
|
+
self._cache.delete(f"user:{token}")
|
|
184
|
+
if self._single_session and user_dict:
|
|
185
|
+
user_id = user_dict.get("id")
|
|
186
|
+
if user_id:
|
|
187
|
+
self._cache.delete(f"user_token:{user_id}")
|
|
188
|
+
|
|
189
|
+
async def _uncache_user_async(self, token: str, user_dict: Optional[dict] = None):
|
|
190
|
+
await self._cache.delete(f"user:{token}")
|
|
191
|
+
if self._single_session and user_dict:
|
|
192
|
+
user_id = user_dict.get("id")
|
|
193
|
+
if user_id:
|
|
194
|
+
await self._cache.delete(f"user_token:{user_id}")
|
|
195
|
+
|
|
146
196
|
# ----------------------
|
|
147
197
|
# 获取用户信息
|
|
148
198
|
# ----------------------
|
|
149
199
|
def say_my_name(self, token: str) -> UserResponse:
|
|
150
200
|
"""同步获取当前用户"""
|
|
201
|
+
if self._async_cache:
|
|
202
|
+
raise RuntimeError("同步缓存未配置")
|
|
203
|
+
|
|
204
|
+
key = f"user:{token}"
|
|
205
|
+
data = self._cache.get(key)
|
|
206
|
+
if data:
|
|
207
|
+
return UserResponse(data)
|
|
208
|
+
|
|
151
209
|
headers = self._headers({"Authorization": f"Bearer {token}"})
|
|
152
210
|
res_dict = request("GET", f"{self._base_url}/api/me", headers=headers)
|
|
211
|
+
|
|
212
|
+
if res_dict.get("success", True):
|
|
213
|
+
self._cache_user(token, res_dict)
|
|
153
214
|
return UserResponse(res_dict)
|
|
154
215
|
|
|
155
216
|
async def say_my_name_async(self, token: str) -> UserResponse:
|
|
217
|
+
if not self._async_cache:
|
|
218
|
+
raise RuntimeError("异步方法只支持异步缓存,请传入 async_cache")
|
|
219
|
+
|
|
156
220
|
"""异步获取当前用户"""
|
|
221
|
+
key = f"user:{token}"
|
|
222
|
+
data = await self._cache.get(key)
|
|
223
|
+
if data:
|
|
224
|
+
return UserResponse(data)
|
|
225
|
+
|
|
157
226
|
headers = self._headers({"Authorization": f"Bearer {token}"})
|
|
158
227
|
res_dict = await self._arequest("GET", f"{self._base_url}/api/me", headers=headers)
|
|
228
|
+
if res_dict.get("success", True):
|
|
229
|
+
await self._cache_user_async(token, res_dict)
|
|
230
|
+
|
|
159
231
|
return UserResponse(res_dict)
|
|
160
232
|
|
|
161
233
|
# 刷新过期时间
|
|
@@ -178,9 +250,46 @@ class OAuthClient:
|
|
|
178
250
|
# 登出
|
|
179
251
|
def logout(self, token: str):
|
|
180
252
|
headers = self._headers({"Authorization": f"Bearer {token}"})
|
|
181
|
-
|
|
253
|
+
resp = request("POST", f"{self._base_url}/api/auth/logout", headers=headers)
|
|
254
|
+
|
|
255
|
+
data = self._cache.get(f"user:{token}")
|
|
256
|
+
self._uncache_user(token, data)
|
|
257
|
+
return resp
|
|
258
|
+
|
|
182
259
|
|
|
183
260
|
async def logout_async(self, token: str) -> dict:
|
|
184
261
|
"""异步获取当前用户"""
|
|
185
262
|
headers = self._headers({"Authorization": f"Bearer {token}"})
|
|
186
|
-
|
|
263
|
+
resp = await self._arequest("POST", f"{self._base_url}/api/auth/logout", headers=headers)
|
|
264
|
+
|
|
265
|
+
data = await self._cache.get(f"user:{token}")
|
|
266
|
+
await self._uncache_user_async(token, data)
|
|
267
|
+
return resp
|
|
268
|
+
|
|
269
|
+
async def main():
|
|
270
|
+
def get_redis_url() -> str:
|
|
271
|
+
return f'redis://default:1q2w3e@localhost:6379/1'
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
import redis.asyncio as redis
|
|
275
|
+
# —— Redis 连接与策略(Bearer + Redis)——
|
|
276
|
+
_redis = redis.from_url(get_redis_url(), decode_responses=True)
|
|
277
|
+
|
|
278
|
+
redis_cache = AsyncRedisCache(_redis)
|
|
279
|
+
|
|
280
|
+
client = OAuthClient("http://localhost:8000/", "bankgpt", "http://localhost:8000/auth/google/custom_callback", single_session=True, cache=redis_cache)
|
|
281
|
+
# authorize = client.authorize("google")
|
|
282
|
+
# print(authorize)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
name = await client.say_my_name_async("Tu72u4SVue4ezadi1v5Ui9r9HjxH7YqbkY_yf5jFtPQ")
|
|
286
|
+
print(name)
|
|
287
|
+
name = await client.say_my_name_async("Tu72u4SVue4ezadi1v5Ui9r9HjxH7YqbkY_yf5jFtPQ")
|
|
288
|
+
print(name)
|
|
289
|
+
name = await client.logout_async("Tu72u4SVue4ezadi1v5Ui9r9HjxH7YqbkY_yf5jFtPQ")
|
|
290
|
+
print(name)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
if __name__ == '__main__':
|
|
294
|
+
asyncio.run(main())
|
|
295
|
+
|
nc_user_manager/utils.py
CHANGED
|
@@ -2,11 +2,26 @@ import json
|
|
|
2
2
|
import urllib.parse
|
|
3
3
|
import urllib.request
|
|
4
4
|
import urllib.error
|
|
5
|
+
from typing import Optional, Dict, Any
|
|
5
6
|
|
|
6
|
-
from exceptions import OAuthError
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
def request(
|
|
9
|
+
method: str,
|
|
10
|
+
url: str,
|
|
11
|
+
params: Optional[Dict[str, Any]] = None,
|
|
12
|
+
headers: Optional[Dict[str, str]] = None,
|
|
13
|
+
json_body: Optional[dict] = None
|
|
14
|
+
) -> dict:
|
|
15
|
+
"""
|
|
16
|
+
发送 HTTP 请求并返回统一结果,不抛异常
|
|
17
|
+
返回格式:
|
|
18
|
+
{
|
|
19
|
+
"status": int, # HTTP 状态码
|
|
20
|
+
"success": bool, # 是否成功 (2xx)
|
|
21
|
+
"body": dict or str, # JSON解析后的body, 或原始body
|
|
22
|
+
"error": Optional[str] # 错误信息
|
|
23
|
+
}
|
|
24
|
+
"""
|
|
10
25
|
# 拼接 GET 参数
|
|
11
26
|
if params:
|
|
12
27
|
query = urllib.parse.urlencode(params)
|
|
@@ -20,20 +35,43 @@ def request(method: str, url: str, params=None, headers=None, json_body=None) ->
|
|
|
20
35
|
headers["Content-Type"] = "application/json"
|
|
21
36
|
|
|
22
37
|
req = urllib.request.Request(url, method=method.upper(), data=data)
|
|
23
|
-
|
|
24
38
|
if headers:
|
|
25
39
|
for k, v in headers.items():
|
|
26
40
|
req.add_header(k, v)
|
|
27
41
|
|
|
42
|
+
result = {
|
|
43
|
+
"status": 0,
|
|
44
|
+
"success": False,
|
|
45
|
+
"message": None,
|
|
46
|
+
"error": None
|
|
47
|
+
}
|
|
48
|
+
|
|
28
49
|
try:
|
|
29
50
|
with urllib.request.urlopen(req) as resp:
|
|
30
51
|
body = resp.read().decode()
|
|
52
|
+
if not body:
|
|
53
|
+
return {}
|
|
31
54
|
try:
|
|
32
55
|
return json.loads(body)
|
|
33
56
|
except json.JSONDecodeError:
|
|
34
|
-
|
|
57
|
+
result["status"] = 200
|
|
58
|
+
result["success"] = False
|
|
59
|
+
result["message"] = body
|
|
60
|
+
result["error"] = "Invalid JSON response"
|
|
61
|
+
return result
|
|
35
62
|
except urllib.error.HTTPError as e:
|
|
36
63
|
body = e.read().decode() if e.fp else None
|
|
37
|
-
|
|
64
|
+
result["status"] = e.code
|
|
65
|
+
try:
|
|
66
|
+
result["message"] = json.loads(body) if body else None
|
|
67
|
+
except json.JSONDecodeError:
|
|
68
|
+
result["message"] = body
|
|
69
|
+
result["error"] = "HTTPError"
|
|
70
|
+
result["success"] = False
|
|
38
71
|
except urllib.error.URLError as e:
|
|
39
|
-
|
|
72
|
+
result["status"] = 0
|
|
73
|
+
result["message"] = None
|
|
74
|
+
result["error"] = f"NetworkError: {e.reason}"
|
|
75
|
+
result["success"] = False
|
|
76
|
+
|
|
77
|
+
return result
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nc-user-terminator
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: OAuth client wrapper for multi-tenant projects
|
|
5
5
|
Author: bw_song
|
|
6
6
|
Author-email: m132777096902@gmail.com
|
|
7
7
|
Requires-Python: >=3.8
|
|
8
8
|
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: cachetools==6.2.0
|
|
9
10
|
Dynamic: author-email
|
|
10
|
-
Dynamic: description-content-type
|
|
11
11
|
Dynamic: requires-python
|
|
12
|
+
|
|
13
|
+
# 更新
|
|
14
|
+
+ V0.1.5
|
|
15
|
+
- 新增本地缓存机制
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
nc_user_manager/__init__.py,sha256=fF3FZD0XUW5YCfTbBeJs3RK-Mnm3IQ7lns6Eb_tMqPU,238
|
|
2
|
+
nc_user_manager/cache.py,sha256=u9ioXDwHmEJHRB4VKI4JU_pp-8Y5O4bLPm8-pwqiMVc,2273
|
|
3
|
+
nc_user_manager/client.py,sha256=71pQ_4YZy1f_BJ9Pc2Md1r57SStqI1qRxnXU9IGAm5Y,11276
|
|
4
|
+
nc_user_manager/exceptions.py,sha256=yUMDrh1HHZF36UUadQNHvJlDgEYSYoYOObs8Q11fjrE,351
|
|
5
|
+
nc_user_manager/models.py,sha256=mDK7zskIcaThxvUUTWVf6eMasw7YaA3hDGVVSd1ZJgo,1088
|
|
6
|
+
nc_user_manager/utils.py,sha256=gxFSaUq0oiymIlvHITu2L7EkU2ZYQmW2q_okmUxGBeU,2319
|
|
7
|
+
nc_user_terminator-0.1.5.dist-info/METADATA,sha256=1uCPVZ-m6nHokYebpNHDAdgJbtNxxJxugWNvzmqxm1U,378
|
|
8
|
+
nc_user_terminator-0.1.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
9
|
+
nc_user_terminator-0.1.5.dist-info/top_level.txt,sha256=kOAUtl6RYo-x3vMJL8It3KCJLoIFPvMUiAAyXjPQTYA,16
|
|
10
|
+
nc_user_terminator-0.1.5.dist-info/RECORD,,
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
nc_user_manager/__init__.py,sha256=fF3FZD0XUW5YCfTbBeJs3RK-Mnm3IQ7lns6Eb_tMqPU,238
|
|
2
|
-
nc_user_manager/client.py,sha256=rGHrFv8ULTp_UXxg8BF-NAmbov9YUgzytJs6Nut7KrE,7254
|
|
3
|
-
nc_user_manager/exceptions.py,sha256=yUMDrh1HHZF36UUadQNHvJlDgEYSYoYOObs8Q11fjrE,351
|
|
4
|
-
nc_user_manager/models.py,sha256=mDK7zskIcaThxvUUTWVf6eMasw7YaA3hDGVVSd1ZJgo,1088
|
|
5
|
-
nc_user_manager/utils.py,sha256=0QmJ9s3mzuYQSlwkcS8BW5XCe23lcoKskEeuqfdZHck,1245
|
|
6
|
-
nc_user_terminator-0.1.3.dist-info/METADATA,sha256=YsxkK7xUovwQCna9nCZyjT5gkYJdVabM01ZmJag9t2g,327
|
|
7
|
-
nc_user_terminator-0.1.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
-
nc_user_terminator-0.1.3.dist-info/top_level.txt,sha256=kOAUtl6RYo-x3vMJL8It3KCJLoIFPvMUiAAyXjPQTYA,16
|
|
9
|
-
nc_user_terminator-0.1.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|