Skip to content

Commit 5819cbd

Browse files
authored
feat: add UserTimezoneMiddleware (#255)
1 parent 5975d59 commit 5819cbd

3 files changed

Lines changed: 160 additions & 1 deletion

File tree

sdks/bkpaas-auth/README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,45 @@ MIDDLEWARE += [
152152
```
153153

154154
设置之后,通过 JWT 透传的用户态会验证后,会写入到 `request.user` 中。注意,配置了不认证用户的网关资源透传的请求,会生成一个有对应用户名的匿名用户对象(`is_authenticated``False`)。
155+
156+
### UserTimezoneMiddleware 用户时区中间件
157+
158+
`UserTimezoneMiddleware` 是一个用于根据用户配置的时区自动设置 Django 时区的中间件。
159+
160+
#### 功能特性
161+
162+
- 自动从用户对象的 `time_zone` 属性读取时区配置
163+
- 时区配置无效时自动回退到默认时区 `settings.TIME_ZONE`
164+
- 响应返回时自动重置时区,避免线程复用导致的时区污染
165+
166+
#### 使用说明
167+
168+
1. **配置要求**
169+
- 用户对象必须包含 `time_zone` 属性(字符串类型)
170+
- 时区名称必须符合 IANA 时区数据库标准(如 "Asia/Shanghai")
171+
172+
2. **中间件顺序**
173+
- 必须放在所有用户认证中间件之后
174+
- 建议放在 `CookieLoginMiddleware` 之后
175+
176+
3. **执行逻辑**
177+
- 未登录用户跳过时区设置
178+
- 读取用户 `time_zone` 属性并验证有效性
179+
- 时区无效时记录警告日志并回退到默认时区
180+
- 响应处理完成后自动重置时区
181+
182+
#### 日志输出示例
183+
184+
```bash
185+
# 时区激活成功
186+
DEBUG: Activated timezone 'Asia/Shanghai' for user 'test_user'
187+
188+
# 时区无效警告
189+
WARNING: Invalid time_zone 'Invalid/Timezone' for user 'test_user', fallback to default. Error: ...
190+
```
191+
192+
#### 注意事项
193+
194+
- 确保用户管理系统正确设置 `time_zone` 属性
195+
- 时区名称必须为有效的 IANA 时区标识符
196+
- 中间件依赖 Django 的时区功能,确保 `USE_TZ = True`

sdks/bkpaas-auth/bkpaas_auth/middlewares.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44
import pickle
55
import time
66
from typing import Dict
7+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
78

89
from django.conf import settings
910
from django.contrib import auth
1011
from django.http import HttpRequest, HttpResponse
1112
from django.utils.deprecation import MiddlewareMixin
1213
from django.utils.encoding import force_str
14+
from django.utils import timezone as dj_timezone
1315

1416
from bkpaas_auth.backends import UniversalAuthBackend
1517
from bkpaas_auth.core.constants import ACCESS_PERMISSION_DENIED_CODE
@@ -94,3 +96,51 @@ def authenticate_and_login(self, request: HttpRequest, credentials: Dict[str, st
9496
# rotation.
9597
if getattr(request, "user", None) != user:
9698
auth.login(request, user)
99+
100+
101+
class UserTimezoneMiddleware(MiddlewareMixin):
102+
"""按用户的时区属性激活 Django 时区。
103+
104+
该中间件从用户管理系统获取用户时区信息并激活,使所有时间相关的序列化输出
105+
都使用用户所在时区的偏移量。
106+
107+
执行逻辑:
108+
1. 未登录用户跳过处理
109+
2. 从 request.user 读取 time_zone 属性
110+
3. 若时区字段缺失或非法,回退到默认时区 settings.TIME_ZONE
111+
4. 在响应返回时重置时区,避免线程复用导致的时区污染
112+
113+
NOTE: 必须放在所有用户认证中间件之后
114+
"""
115+
116+
def process_request(self, request):
117+
# Ignore request without user attribute or anonymous user
118+
if not hasattr(request, "user") or not request.user.is_authenticated:
119+
return
120+
121+
user = request.user
122+
tz_name = getattr(user, "time_zone", None)
123+
124+
# Try to activate user's timezone if it's a non-empty string
125+
if tz_name and isinstance(tz_name, str):
126+
try:
127+
user_tz = ZoneInfo(tz_name)
128+
dj_timezone.activate(user_tz)
129+
except ZoneInfoNotFoundError as e:
130+
logger.warning(
131+
"Invalid time_zone '%s' for user '%s', fallback to default. Error: %s",
132+
tz_name,
133+
user.username,
134+
str(e),
135+
)
136+
else:
137+
logger.debug("Activated timezone '%s' for user '%s'", tz_name, user.username)
138+
return
139+
140+
# Fallback to default timezone when time_zone is empty or invalid
141+
dj_timezone.activate(dj_timezone.get_default_timezone())
142+
143+
def process_response(self, request, response):
144+
"""重置时区"""
145+
dj_timezone.deactivate()
146+
return response

sdks/bkpaas-auth/tests/test_middlewares.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@
1212
from django.contrib.sessions.middleware import SessionMiddleware
1313
from django.http import HttpRequest
1414
from django.test.utils import override_settings
15+
from django.utils import timezone as dj_timezone
1516

1617
from bkpaas_auth.backends import UniversalAuthBackend
1718
from bkpaas_auth.core.constants import ACCESS_PERMISSION_DENIED_CODE, ProviderType
1819
from bkpaas_auth.core.exceptions import AccessPermissionDenied
1920
from bkpaas_auth.core.token import LoginToken
20-
from bkpaas_auth.middlewares import CookieLoginMiddleware, auth
21+
from bkpaas_auth.middlewares import CookieLoginMiddleware, auth, UserTimezoneMiddleware
2122
from bkpaas_auth.models import User
2223
from tests.utils import generate_random_string
2324

@@ -182,3 +183,69 @@ def test_auth(self, db, bk_token, dj_request):
182183
assert isinstance(user, UserModel)
183184
assert not isinstance(user, User)
184185
assert isinstance(user.pk, int)
186+
187+
188+
class TestUserTimezoneMiddleware:
189+
"""Test cases for UserTimezoneMiddleware"""
190+
191+
@pytest.fixture
192+
def middleware(self):
193+
return UserTimezoneMiddleware(MagicMock())
194+
195+
@pytest.fixture
196+
def authenticated_user(self):
197+
"""Create a mock authenticated user"""
198+
user = MagicMock()
199+
user.is_authenticated = True
200+
return user
201+
202+
@pytest.fixture(autouse=True)
203+
def setup_timezone(self):
204+
"""Reset timezone before and after each test to avoid pollution"""
205+
with override_settings(TIME_ZONE="UTC"):
206+
dj_timezone.deactivate()
207+
yield
208+
dj_timezone.deactivate()
209+
210+
def test_skip_request_without_user_attr(self, rf, middleware):
211+
"""Test that requests without user attribute don't change timezone"""
212+
middleware.process_request(rf.get("/"))
213+
assert dj_timezone.get_current_timezone_name() == "UTC"
214+
215+
def test_skip_anonymous_user(self, rf, middleware):
216+
"""Test that anonymous users don't change timezone"""
217+
request = rf.get("/")
218+
request.user = AnonymousUser()
219+
middleware.process_request(request)
220+
assert dj_timezone.get_current_timezone_name() == "UTC"
221+
222+
def test_activate_valid_user_timezone(self, rf, middleware, authenticated_user):
223+
"""Test that valid user timezone is actually activated"""
224+
request = rf.get("/")
225+
authenticated_user.time_zone = "America/New_York"
226+
request.user = authenticated_user
227+
middleware.process_request(request)
228+
assert dj_timezone.get_current_timezone_name() == "America/New_York"
229+
230+
@pytest.mark.parametrize(
231+
("time_zone_value", "has_attr"),
232+
[
233+
("Invalid/Timezone", True),
234+
("", True),
235+
(None, True),
236+
(None, False),
237+
(123, True),
238+
],
239+
ids=["invalid_timezone", "empty_string", "none_value", "no_attr", "non_string_type"],
240+
)
241+
@override_settings(TIME_ZONE="Asia/Shanghai")
242+
def test_fallback_to_default_timezone(self, rf, middleware, authenticated_user, time_zone_value, has_attr):
243+
"""Test fallback to default timezone for various edge cases"""
244+
request = rf.get("/")
245+
if has_attr:
246+
authenticated_user.time_zone = time_zone_value
247+
else:
248+
del authenticated_user.time_zone
249+
request.user = authenticated_user
250+
middleware.process_request(request)
251+
assert dj_timezone.get_current_timezone_name() == "Asia/Shanghai"

0 commit comments

Comments
 (0)