Skip to content

Commit e6ab01e

Browse files
committed
feat: add user and role management pages with API integration
- Implemented user management functionality in UsersPage including user creation, editing, deletion, and role assignment. - Added role management functionality in RolesPage with role creation, editing, deletion, and path rule management. - Created users API for handling user-related operations. - Created roles API for handling role-related operations. - Integrated permissions handling in both user and role management. - Enhanced UI with Ant Design components for better user experience.
1 parent 4a2e011 commit e6ab01e

File tree

33 files changed

+3462
-10
lines changed

33 files changed

+3462
-10
lines changed

api/routers.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
from domain.virtual_fs.mapping import s3_api, webdav_api
1717
from domain.virtual_fs.search import search_api
1818
from domain.audit import api as audit
19+
from domain.permission import api as permission
20+
from domain.user import api as user
21+
from domain.role import api as role
1922

2023

2124
def include_routers(app: FastAPI):
@@ -38,3 +41,6 @@ def include_routers(app: FastAPI):
3841
app.include_router(offline_downloads.router)
3942
app.include_router(email.router)
4043
app.include_router(audit.router)
44+
app.include_router(permission.router)
45+
app.include_router(user.router)
46+
app.include_router(role.router)

domain/auth/service.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ async def get_user_db(cls, username_or_email: str) -> UserInDB | None:
140140
email=user.email,
141141
full_name=user.full_name,
142142
disabled=user.disabled,
143+
is_admin=user.is_admin,
143144
hashed_password=user.hashed_password,
144145
)
145146
return None
@@ -166,12 +167,14 @@ async def register_user(cls, payload: RegisterRequest):
166167
if exists:
167168
raise HTTPException(status_code=400, detail="用户名已存在")
168169
hashed = cls.get_password_hash(payload.password)
170+
# 第一个用户自动成为超级管理员
169171
user = await UserAccount.create(
170172
username=payload.username,
171173
email=payload.email,
172174
full_name=payload.full_name,
173175
hashed_password=hashed,
174176
disabled=False,
177+
is_admin=True, # 第一个用户是超级管理员
175178
)
176179
return user
177180

@@ -195,6 +198,13 @@ async def login(cls, form: OAuth2PasswordRequestForm) -> Token:
195198
detail="用户名或密码错误",
196199
headers={"WWW-Authenticate": "Bearer"},
197200
)
201+
202+
# 更新最后登录时间
203+
db_user = await UserAccount.get_or_none(id=user.id)
204+
if db_user:
205+
db_user.last_login = _now()
206+
await db_user.save(update_fields=["last_login"])
207+
198208
access_token_expires = timedelta(minutes=cls.access_token_expire_minutes)
199209
access_token = await cls.create_access_token(
200210
data={"sub": user.username}, expires_delta=access_token_expires
@@ -212,6 +222,7 @@ def _build_profile(cls, user: User | UserInDB | UserAccount) -> dict:
212222
"email": getattr(user, "email", None),
213223
"full_name": getattr(user, "full_name", None),
214224
"gravatar_url": gravatar_url,
225+
"is_admin": getattr(user, "is_admin", False),
215226
}
216227

217228
@classmethod

domain/auth/types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class User(BaseModel):
1616
email: str | None = None
1717
full_name: str | None = None
1818
disabled: bool | None = None
19+
is_admin: bool = False
1920

2021

2122
class UserInDB(User):

domain/permission/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .service import PermissionService
2+
from .matcher import PathMatcher
3+
4+
__all__ = ["PermissionService", "PathMatcher"]

domain/permission/api.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from typing import Annotated
2+
from fastapi import APIRouter, Depends
3+
4+
from domain.auth.service import get_current_active_user
5+
from domain.auth.types import User
6+
from .service import PermissionService
7+
from .types import (
8+
PathPermissionCheck,
9+
PathPermissionResult,
10+
UserPermissions,
11+
PermissionInfo,
12+
)
13+
14+
router = APIRouter(prefix="/api", tags=["permissions"])
15+
16+
17+
@router.get("/permissions", response_model=list[PermissionInfo])
18+
async def get_all_permissions(
19+
current_user: Annotated[User, Depends(get_current_active_user)]
20+
) -> list[PermissionInfo]:
21+
"""获取所有权限定义"""
22+
return await PermissionService.get_all_permissions()
23+
24+
25+
@router.get("/me/permissions", response_model=UserPermissions)
26+
async def get_my_permissions(
27+
current_user: Annotated[User, Depends(get_current_active_user)]
28+
) -> UserPermissions:
29+
"""获取当前用户的有效权限"""
30+
return await PermissionService.get_user_permissions(current_user.id)
31+
32+
33+
@router.post("/me/check-path", response_model=PathPermissionResult)
34+
async def check_path_permission(
35+
data: PathPermissionCheck,
36+
current_user: Annotated[User, Depends(get_current_active_user)],
37+
) -> PathPermissionResult:
38+
"""检查当前用户对某路径的权限"""
39+
return await PermissionService.check_path_permission_detailed(
40+
current_user.id, data.path, data.action
41+
)

domain/permission/matcher.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import re
2+
import fnmatch
3+
from functools import lru_cache
4+
5+
6+
class PathMatcher:
7+
"""路径匹配器,支持精确匹配、通配符匹配和正则匹配"""
8+
9+
@classmethod
10+
def normalize_path(cls, path: str) -> str:
11+
"""规范化路径"""
12+
if not path:
13+
return "/"
14+
# 确保以 / 开头
15+
if not path.startswith("/"):
16+
path = "/" + path
17+
# 移除末尾的 /(除了根路径)
18+
if path != "/" and path.endswith("/"):
19+
path = path.rstrip("/")
20+
return path
21+
22+
@classmethod
23+
def get_parent_path(cls, path: str) -> str | None:
24+
"""获取父目录路径"""
25+
path = cls.normalize_path(path)
26+
if path == "/":
27+
return None
28+
parent = "/".join(path.rsplit("/", 1)[:-1])
29+
return parent if parent else "/"
30+
31+
@classmethod
32+
def match_pattern(cls, path: str, pattern: str, is_regex: bool = False) -> bool:
33+
"""
34+
匹配路径和模式
35+
36+
Args:
37+
path: 要匹配的路径
38+
pattern: 匹配模式
39+
is_regex: 是否为正则表达式
40+
41+
Returns:
42+
是否匹配
43+
"""
44+
path = cls.normalize_path(path)
45+
pattern = cls.normalize_path(pattern)
46+
47+
if is_regex:
48+
return cls._match_regex(path, pattern)
49+
else:
50+
return cls._match_glob(path, pattern)
51+
52+
@classmethod
53+
def _match_regex(cls, path: str, pattern: str) -> bool:
54+
"""正则表达式匹配"""
55+
try:
56+
# 限制正则表达式的复杂度,防止 ReDoS 攻击
57+
if len(pattern) > 500:
58+
return False
59+
regex = re.compile(pattern)
60+
return bool(regex.match(path))
61+
except re.error:
62+
return False
63+
64+
@classmethod
65+
def _match_glob(cls, path: str, pattern: str) -> bool:
66+
"""
67+
通配符匹配
68+
69+
支持的语法:
70+
- * : 匹配单层目录中的任意字符
71+
- ** : 匹配任意层级目录
72+
- ? : 匹配单个字符
73+
"""
74+
# 精确匹配
75+
if pattern == path:
76+
return True
77+
78+
# 处理 ** 通配符
79+
if "**" in pattern:
80+
return cls._match_double_star(path, pattern)
81+
82+
# 使用 fnmatch 进行标准通配符匹配
83+
return fnmatch.fnmatch(path, pattern)
84+
85+
@classmethod
86+
def _match_double_star(cls, path: str, pattern: str) -> bool:
87+
"""处理 ** 通配符匹配"""
88+
# 将 ** 替换为特殊标记
89+
parts = pattern.split("**")
90+
91+
if len(parts) == 2:
92+
prefix, suffix = parts
93+
# 移除 prefix 末尾的 / 和 suffix 开头的 /
94+
prefix = prefix.rstrip("/") if prefix else ""
95+
suffix = suffix.lstrip("/") if suffix else ""
96+
97+
# 检查前缀匹配
98+
if prefix and not path.startswith(prefix):
99+
return False
100+
101+
# 如果没有后缀,只需要前缀匹配
102+
if not suffix:
103+
return True
104+
105+
# 检查后缀匹配
106+
remaining = path[len(prefix):].lstrip("/") if prefix else path.lstrip("/")
107+
108+
# 后缀可以出现在任意位置
109+
if "*" in suffix or "?" in suffix:
110+
# 后缀包含通配符,逐层检查
111+
path_parts = remaining.split("/")
112+
suffix_parts = suffix.split("/")
113+
114+
# 简化处理:检查路径的最后几层是否与后缀匹配
115+
if len(path_parts) >= len(suffix_parts):
116+
tail = "/".join(path_parts[-len(suffix_parts):])
117+
return fnmatch.fnmatch(tail, suffix)
118+
return False
119+
else:
120+
# 后缀是精确字符串
121+
return remaining.endswith(suffix) or ("/" + suffix) in remaining or remaining == suffix
122+
123+
# 多个 ** 的情况,使用简化匹配
124+
regex_pattern = pattern.replace("**", ".*").replace("*", "[^/]*").replace("?", ".")
125+
try:
126+
return bool(re.match(f"^{regex_pattern}$", path))
127+
except re.error:
128+
return False
129+
130+
@classmethod
131+
def get_pattern_specificity(cls, pattern: str, is_regex: bool = False) -> int:
132+
"""
133+
计算模式的具体程度(用于优先级排序)
134+
135+
返回值越大表示模式越具体
136+
"""
137+
pattern = cls.normalize_path(pattern)
138+
139+
if is_regex:
140+
# 正则表达式具体程度较低
141+
return len(pattern) // 2
142+
143+
# 精确路径最具体
144+
if "*" not in pattern and "?" not in pattern:
145+
return len(pattern) * 10
146+
147+
# 计算非通配符部分的长度
148+
specificity = 0
149+
parts = pattern.split("/")
150+
for part in parts:
151+
if part == "**":
152+
specificity += 1
153+
elif "*" in part or "?" in part:
154+
specificity += 5
155+
else:
156+
specificity += 10
157+
158+
return specificity

0 commit comments

Comments
 (0)