Skip to content

Commit 93ff291

Browse files
committed
feat: configure admin session lifetime (#484)
1 parent 2c51384 commit 93ff291

5 files changed

Lines changed: 108 additions & 7 deletions

File tree

apps/admin/dependencies.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@
88
import hmac
99
import json
1010
import time
11-
from core.settings import settings
11+
from core.settings import (
12+
ADMIN_SESSION_EXPIRE_DEFAULT,
13+
ADMIN_SESSION_EXPIRE_MAX,
14+
ADMIN_SESSION_EXPIRE_MIN,
15+
settings,
16+
)
1217
from apps.admin.services import FileService, ConfigService, LocalFileService
1318

1419

@@ -19,17 +24,35 @@ def _get_jwt_secret() -> bytes:
1924
return secret.encode()
2025

2126

22-
def create_token(data: dict, expires_in: int = 3600 * 24 * 30) -> str:
27+
def get_admin_session_expire_seconds() -> int:
28+
try:
29+
expires_in = int(
30+
getattr(settings, "adminSessionExpire", ADMIN_SESSION_EXPIRE_DEFAULT)
31+
)
32+
except (TypeError, ValueError):
33+
return ADMIN_SESSION_EXPIRE_DEFAULT
34+
if (
35+
not ADMIN_SESSION_EXPIRE_MIN <= expires_in <= ADMIN_SESSION_EXPIRE_MAX
36+
or expires_in % ADMIN_SESSION_EXPIRE_MIN != 0
37+
):
38+
return ADMIN_SESSION_EXPIRE_DEFAULT
39+
return expires_in
40+
41+
42+
def create_token(data: dict, expires_in: int | None = None) -> str:
2343
"""
2444
创建JWT token
2545
:param data: 数据负载
2646
:param expires_in: 过期时间(秒)
2747
"""
48+
token_lifetime = (
49+
get_admin_session_expire_seconds() if expires_in is None else expires_in
50+
)
2851
header = base64.b64encode(
2952
json.dumps({"alg": "HS256", "typ": "JWT"}).encode()
3053
).decode()
3154
payload = base64.b64encode(
32-
json.dumps({**data, "exp": int(time.time()) + expires_in}).encode()
55+
json.dumps({**data, "exp": int(time.time()) + token_lifetime}).encode()
3356
).decode()
3457

3558
signature = hmac.new(_get_jwt_secret(), f"{header}.{payload}".encode(), "sha256").digest()

apps/admin/services.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66

77
from core.response import APIResponse
88
from core.storage import FileStorageInterface, storages
9-
from core.settings import settings
9+
from core.settings import (
10+
ADMIN_SESSION_EXPIRE_MAX,
11+
ADMIN_SESSION_EXPIRE_MIN,
12+
settings,
13+
)
1014
from core.config import refresh_settings
1115
from core.security import INTERNAL_CONFIG_KEYS, generate_jwt_secret
1216
from apps.base.models import FileCodes, KeyValue
@@ -1496,6 +1500,7 @@ async def share_local_file(self, item):
14961500

14971501
class ConfigService:
14981502
INT_FIELDS = {
1503+
"adminSessionExpire",
14991504
"enableChunk",
15001505
"errorCount",
15011506
"errorMinute",
@@ -1554,6 +1559,23 @@ async def update_config(self, data: dict):
15541559
except (TypeError, ValueError):
15551560
raise HTTPException(status_code=400, detail=f"{key} 配置值格式错误")
15561561

1562+
try:
1563+
session_expire = int(next_config.get("adminSessionExpire"))
1564+
except (TypeError, ValueError):
1565+
raise HTTPException(
1566+
status_code=400,
1567+
detail="adminSessionExpire 配置值格式错误",
1568+
)
1569+
if (
1570+
not ADMIN_SESSION_EXPIRE_MIN <= session_expire <= ADMIN_SESSION_EXPIRE_MAX
1571+
or session_expire % ADMIN_SESSION_EXPIRE_MIN != 0
1572+
):
1573+
raise HTTPException(
1574+
status_code=400,
1575+
detail="adminSessionExpire 必须是 1 到 365 个整天",
1576+
)
1577+
next_config["adminSessionExpire"] = session_expire
1578+
15571579
if admin_password_changed:
15581580
next_config["jwt_secret"] = generate_jwt_secret()
15591581

apps/admin/views.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@
3131
)
3232
from core.response import APIResponse
3333
from apps.base.models import FileCodes, KeyValue
34-
from apps.admin.dependencies import create_token
34+
from apps.admin.dependencies import (
35+
create_token,
36+
get_admin_session_expire_seconds,
37+
verify_token,
38+
)
3539
from core.settings import settings
3640
from core.utils import get_now, verify_password
3741

@@ -53,8 +57,18 @@ async def login(data: LoginData):
5357
if not verify_password(data.password, settings.admin_token):
5458
raise HTTPException(status_code=401, detail="密码错误")
5559

56-
token = create_token({"is_admin": True})
57-
return APIResponse(detail={"token": token, "token_type": "Bearer"})
60+
expires_in = get_admin_session_expire_seconds()
61+
token = create_token({"is_admin": True}, expires_in=expires_in)
62+
return APIResponse(
63+
detail={
64+
"id": "admin",
65+
"username": "admin",
66+
"token": token,
67+
"token_type": "Bearer",
68+
"expires_at": verify_token(token)["exp"],
69+
"expires_in": expires_in,
70+
}
71+
)
5872

5973

6074
@admin_api.get("/verify")

core/settings.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
# @Software: PyCharm
55
from pathlib import Path
66

7+
ADMIN_SESSION_EXPIRE_DEFAULT = 30 * 24 * 60 * 60
8+
ADMIN_SESSION_EXPIRE_MIN = 24 * 60 * 60
9+
ADMIN_SESSION_EXPIRE_MAX = 365 * 24 * 60 * 60
10+
711
BASE_DIR = Path(__file__).resolve().parent.parent
812
data_root = BASE_DIR / "data"
913

@@ -40,6 +44,7 @@
4044
"webdav_proxy": 0,
4145
"admin_token": "",
4246
"jwt_secret": "",
47+
"adminSessionExpire": ADMIN_SESSION_EXPIRE_DEFAULT,
4348
"openUpload": 1,
4449
"uploadSize": 1024 * 1024 * 10,
4550
"allowed_file_types": ["*"],

tests/test_admin_security.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
import unittest
44

55
import apps.admin.services as admin_services
6+
import apps.admin.views as admin_views
7+
import apps.admin.dependencies as admin_dependencies
68
import core.config as core_config
79
from apps.admin.dependencies import create_token, verify_token
10+
from apps.admin.schemas import LoginData
811
from apps.admin.services import ConfigService
12+
from fastapi import HTTPException
913
from main import parse_setup_options
1014
from core.security import (
1115
LEGACY_DEFAULT_ADMIN_TOKEN,
@@ -119,6 +123,26 @@ def test_admin_jwt_signature_uses_independent_secret(self):
119123
with self.assertRaises(ValueError):
120124
verify_token(token)
121125

126+
def test_configured_session_lifetime_is_returned_by_login(self):
127+
settings.admin_token = hash_password("admin-password")
128+
settings.jwt_secret = "j" * 48
129+
settings.adminSessionExpire = 90 * 24 * 60 * 60
130+
original_time = admin_dependencies.time.time
131+
admin_dependencies.time.time = lambda: 1_800_000_000
132+
try:
133+
response = asyncio.run(
134+
admin_views.login(LoginData(password="admin-password"))
135+
)
136+
payload = verify_token(response.detail["token"])
137+
finally:
138+
admin_dependencies.time.time = original_time
139+
140+
self.assertEqual(response.detail["expires_in"], 90 * 24 * 60 * 60)
141+
self.assertEqual(
142+
response.detail["expires_at"], 1_800_000_000 + 90 * 24 * 60 * 60
143+
)
144+
self.assertEqual(payload["exp"], response.detail["expires_at"])
145+
122146

123147
class FakeKeyValue:
124148
saved_value = None
@@ -158,6 +182,19 @@ async def update_or_create(cls, key, defaults):
158182

159183

160184
class ConfigServiceSecurityTests(SettingsOverrideMixin, unittest.TestCase):
185+
def test_admin_session_lifetime_rejects_out_of_range_values(self):
186+
settings.user_config = copy.deepcopy(DEFAULT_CONFIG)
187+
188+
for invalid_value in (0, 86399, 90000, 365 * 24 * 60 * 60 + 1):
189+
with self.subTest(invalid_value=invalid_value):
190+
with self.assertRaises(HTTPException) as context:
191+
asyncio.run(
192+
ConfigService().update_config(
193+
{"adminSessionExpire": invalid_value}
194+
)
195+
)
196+
self.assertEqual(context.exception.status_code, 400)
197+
161198
def test_admin_password_update_rotates_jwt_secret(self):
162199
old_secret = "j" * 48
163200
settings.user_config = {

0 commit comments

Comments
 (0)