Skip to content

Commit 7fa0dc3

Browse files
committed
refactor: redesign/reimplement optional anonymous user
1 parent a84aa93 commit 7fa0dc3

17 files changed

Lines changed: 382 additions & 131 deletions

README.md

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -57,19 +57,6 @@ docker run -dit --restart unless-stopped \
5757

5858
[Documentation at GitHub Page](https://rexzhang.github.io/asgi-webdav/)
5959

60-
## TODO
61-
62-
- Digest auth support neon
63-
- SQL database provider
64-
- Test big(1GB+) file in MemoryProvider
65-
- display server info in page `/_/admin` or `/_/`
66-
- Fail2ban(docker)
67-
- NFSProvider
68-
- logout at the web page
69-
- Fix MemoryProvider with macOS finder(create new file)
70-
- rewrite MemoryProvider with mmap
71-
- generate template URL for share(read only)
72-
7360
## Related Projects
7461

7562
- <https://github.com/bootrino/reactoxide>

TODO

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
进行中:
2+
☐ 匿名账号支持
3+
✔ 重新设计 Config 部分 @done(25-10-08)
4+
✔ 匿名账号在账号清单中生效 @done(25-10-19)
5+
✔ 匿名账号能通过 HTTP Auth 登录 @done(25-10-19)
6+
✔ 账号验证错误后正确返回401 @done(25-10-29)
7+
✔ 匿名账号在没有权限的路径返回 401 @done(25-10-29)
8+
✔ 匿名用户下对没有权限的路径返回 401, 并强制触发浏览器提示输入账号密码 @done(25-10-30)
9+
☐ 配置文件现代化
10+
保存对 JSON 格式的支持
11+
TOML 作为配置文件的第一格式
12+
✔ pydantic => dataclass @done
13+
✔ 添加 TOML 支持 @done
14+
☐ 使用 TOML 方便方便添加注释的特性增加各种使用场景配置范例 @long-term
15+
☐ 以 TOML 下的可读性为标准,适当调整配置文件结构 @long-term
16+
17+
长期性任务:
18+
整理好后迁移到 github issues
19+
- 更好的类型标注
20+
主要解决类型推断问题, 提高对静态类型检查工具的支持
21+
终极目标为: 能通过mypy的检查
22+
- 在维护代码时顺便解决相关代码出现的类型推断错误
23+
☐ 消除 DAVRequest 相关的类型推断错误
24+
☐ 消除 DAVResponse 相关的类型推断错误
25+
- 解耦/分离可作为第三方库的核心代码和实现应用的代码
26+
降低耦合度, 让其他项目能更便捷的添加 webdav 能力
27+
终极目标为: 实现将核心代码剥离为一个独立的库
28+
- 核心代码作为 ASGI app 分发
29+
- 文档问题
30+
☐ 规范文档结构
31+
☐ 文档自动部署失效问题
32+
33+
待实现:
34+
☐ 重构 DAVResponse 来统一 401 的响应行为 @high @next-release
35+
☐ 浏览器请求才弹认证请求框, 服务器返回 401 时不弹认证框
36+
☐ 重构部分 DAVResponse 代码, 这个与压缩依赖部分一起修改
37+
☐ 顺带解决 litmus 的一个告警?
38+
☐ 移除第三方压缩依赖,添加sztd支持 @delay dataclass-wizard 不兼容 py3.14
39+
☐ Digest auth support neon @low @@deplay 需要时间过多
40+
41+
待确定实现方案:
42+
☐ 统一 user/account 的概念 @low
43+
当前 user/account 的概念是混合的, 在代码中也是
44+
需要做出明确的定义和规则; 统一为一个名称?
45+
☐ 完善的 LDAP 支持 @high
46+
☐ display server info in page `/_/admin` or `/_/` @high
47+
☐ Fail2ban(docker) @critical
48+
☐ Fix MemoryProvider with macOS finder(create new file)
49+
☐ NFSProvider
50+
☐ Test big(1GB+) file in MemoryProvider
51+
☐ SQL database provider
52+
☐ 支持 docker health check
53+
54+
待确定可行性:
55+
☐ generate random URL for share(read only) @high
56+
☐ rewrite MemoryProvider with mmap or other
57+
☐ logout at the web page

asgi_webdav/auth.py

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
)
2424
from asgi_webdav.config import Config
2525
from asgi_webdav.constants import DAVUser
26-
from asgi_webdav.exception import DAVExceptionAuthFailed, DAVExceptionConfigPaserFailed
26+
from asgi_webdav.exception import DAVExceptionAuthFailed, DAVExceptionConfig
2727
from asgi_webdav.request import DAVRequest
2828
from asgi_webdav.response import DAVResponse
2929

@@ -534,7 +534,7 @@ def build_request_digest(
534534
class DAVAuth:
535535
realm = "ASGI-WebDAV"
536536
user_mapping: dict[str, DAVUser]
537-
anonymous_user: DAVUser | None = None
537+
anonymous_auto_match_user: DAVUser | None = None
538538

539539
def __init__(self, config: Config):
540540
self.config = config
@@ -551,15 +551,22 @@ def __init__(self, config: Config):
551551
self.user_mapping[config_account.username] = user
552552
logger.info(f"Register User: {user}")
553553

554-
if self.config.anonymous_username:
555-
self.anonymous_user = self.user_mapping.get(self.config.anonymous_username)
556-
if self.anonymous_user is None:
557-
# TODO: check it in config's reinit funtion, raise first at config.py
558-
raise DAVExceptionConfigPaserFailed(
559-
"please check config's anonymous_username"
560-
)
561-
562-
logger.info(f"Register Anonymous User: {self.anonymous_user}")
554+
if (
555+
self.config.anonymous.enable
556+
and self.config.anonymous.allow_missing_auth_header
557+
):
558+
self.anonymous_auto_match_user = self.user_mapping.get(
559+
self.config.anonymous.user.username
560+
)
561+
if self.anonymous_auto_match_user is None:
562+
message = f"Anonymous auto match user: {self.config.anonymous.user.username} not found, please check config"
563+
logger.error(message)
564+
raise DAVExceptionConfig(message)
565+
566+
self.anonymous_auto_match_user.anonymous = True
567+
logger.info(
568+
f"Enable anonymous auto match, user: {self.config.anonymous.user.username}"
569+
)
563570

564571
self.http_basic_auth = HTTPBasicAuth(
565572
realm=self.realm,
@@ -569,15 +576,12 @@ def __init__(self, config: Config):
569576
self.http_digest_auth = HTTPDigestAuth(realm=self.realm, secret=uuid4().hex)
570577

571578
async def pick_out_user(self, request: DAVRequest) -> tuple[DAVUser | None, str]:
572-
# TODO: support special user name: anonymous
573-
574579
authorization_header = request.headers.get(b"authorization")
575580
if authorization_header is None:
576-
if self.anonymous_user is None:
581+
if self.anonymous_auto_match_user is None:
577582
return None, "miss header: authorization"
578583
else:
579-
# Server has the anonymous option able
580-
return self.anonymous_user, ""
584+
return self.anonymous_auto_match_user, ""
581585

582586
index = authorization_header.find(b" ")
583587
if index == -1:

asgi_webdav/config.py

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@
1616
from asgi_webdav.constants import (
1717
DEFAULT_FILENAME_CONTENT_TYPE_MAPPING,
1818
DEFAULT_HTTP_BASIC_AUTH_CACHE_TIMEOUT,
19+
DEFAULT_PASSWORD,
20+
DEFAULT_PASSWORD_ANONYMOUS,
21+
DEFAULT_PERMISSIONS,
1922
DEFAULT_SUFFIX_CONTENT_TYPE_MAPPING,
23+
DEFAULT_USERNAME,
24+
DEFAULT_USERNAME_ANONYMOUS,
2025
AppEntryParameters,
2126
DAVCompressLevel,
2227
)
@@ -44,6 +49,17 @@ class User:
4449
admin: bool = False
4550

4651

52+
@dataclass
53+
class Anonymous:
54+
enable: bool = False
55+
user: User = field(
56+
default_factory=lambda: User(
57+
DEFAULT_USERNAME_ANONYMOUS, DEFAULT_PASSWORD_ANONYMOUS, DEFAULT_PERMISSIONS
58+
)
59+
)
60+
allow_missing_auth_header: bool = True
61+
62+
4763
@dataclass
4864
class HTTPBasicAuth:
4965
# enable: bool = True
@@ -149,7 +165,8 @@ class Logging:
149165
class Config(JSONPyWizard):
150166
# auth
151167
account_mapping: list[User] = field(default_factory=list)
152-
anonymous_username: str = ""
168+
169+
anonymous: Anonymous = field(default_factory=Anonymous)
153170

154171
http_basic_auth: HTTPBasicAuth = field(default_factory=HTTPBasicAuth)
155172
http_digest_auth: HTTPDigestAuth = field(default_factory=HTTPDigestAuth)
@@ -183,7 +200,7 @@ def _update_from_env_config(self):
183200
User(
184201
username=env_config.username,
185202
password=env_config.password,
186-
permissions=["+"],
203+
permissions=DEFAULT_PERMISSIONS,
187204
),
188205
)
189206
logger.info(f"Add user from ENV: {self.account_mapping[0].username}")
@@ -208,7 +225,7 @@ def _update_from_app_args(self, aep: AppEntryParameters):
208225
User(
209226
username=aep.admin_user[0],
210227
password=aep.admin_user[1],
211-
permissions=["+"],
228+
permissions=DEFAULT_PERMISSIONS,
212229
admin=True,
213230
),
214231
)
@@ -228,17 +245,27 @@ def _update_from_app_args(self, aep: AppEntryParameters):
228245
else:
229246
self.provider_mapping[root_path_index].uri = root_path_uri
230247

231-
def _fix_config(self):
232-
# account_mapping
248+
def _complete_config(self):
249+
# auth - anonymous
250+
if self.anonymous.enable:
251+
self.account_mapping.append(self.anonymous.user)
252+
logger.info(
253+
f"Enable anonymous user: {self.anonymous.user.username}, permissions: {self.anonymous.user.permissions}, admin: {self.anonymous.user.admin}"
254+
)
255+
256+
# auth - default(admin) user
233257
if len(self.account_mapping) == 0:
234258
self.account_mapping.append(
235-
User(username="username", password="password", permissions=["+"])
259+
User(
260+
username=DEFAULT_USERNAME,
261+
password=DEFAULT_PASSWORD,
262+
permissions=DEFAULT_PERMISSIONS,
263+
admin=True,
264+
)
265+
)
266+
logger.warning(
267+
f"Add defalut(admin) user: {DEFAULT_USERNAME}, password:{DEFAULT_PASSWORD}, permissions: {DEFAULT_PERMISSIONS}"
236268
)
237-
logger.warning("Add defalut user: username/password")
238-
239-
if len(self.account_mapping) == 1:
240-
self.account_mapping[0].admin = True
241-
logger.warning("Set the only account as admin")
242269

243270
# provider - default
244271
if len(self.provider_mapping) == 0:
@@ -263,7 +290,7 @@ def update_from_app_args_and_env_and_default_value(self, aep: AppEntryParameters
263290
"""
264291
self._update_from_env_config()
265292
self._update_from_app_args(aep)
266-
self._fix_config()
293+
self._complete_config()
267294

268295

269296
_config: Config = Config()
@@ -273,20 +300,24 @@ def get_config() -> Config:
273300
return _config
274301

275302

276-
def get_config_copy_from_dict(data: dict) -> Config:
277-
return Config.from_dict(data)
303+
def get_config_copy_from_dict(data: dict, complete_config: bool = False) -> Config:
304+
config = Config.from_dict(data)
305+
if complete_config:
306+
config._complete_config()
307+
308+
return config
278309

279310

280-
def reinit_config_from_dict(data: dict) -> Config:
311+
def reinit_config_from_dict(data: dict, complete_config: bool = False) -> Config:
281312
global _config
282313

283314
logger.debug("Load config value from python object(dict)")
284-
_config = get_config_copy_from_dict(data)
315+
_config = get_config_copy_from_dict(data, complete_config)
285316

286317
return _config
287318

288319

289-
def reinit_config_from_file(file_name: str) -> bool:
320+
def reinit_config_from_file(file_name: str, complete_config: bool = False) -> bool:
290321
file = Path(file_name)
291322
match file.suffix:
292323
case ".json":
@@ -314,6 +345,6 @@ def reinit_config_from_file(file_name: str) -> bool:
314345
logger.error(e)
315346
return False
316347

317-
reinit_config_from_dict(data)
348+
reinit_config_from_dict(data, complete_config)
318349
logger.info(f"Load config from file: [{file}] success!")
319350
return True

asgi_webdav/constants.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -345,18 +345,25 @@ class DAVCompressLevel(Enum):
345345

346346
# Authentication ---
347347

348-
DEFAULT_HTTP_BASIC_AUTH_CACHE_TIMEOUT = (
349-
-1
350-
) # -1 means cache does not expire, 0 mean cache is disabled,
348+
DEFAULT_USERNAME = "username"
349+
DEFAULT_PASSWORD = "password"
350+
DEFAULT_USERNAME_ANONYMOUS = "anonymous"
351+
DEFAULT_PASSWORD_ANONYMOUS = ""
352+
DEFAULT_PERMISSIONS = ["+"]
353+
354+
# -1 means cache does not expire, 0 mean cache is disabled,
351355
# >0 is seconds until cache entry expires
356+
DEFAULT_HTTP_BASIC_AUTH_CACHE_TIMEOUT = -1
352357

353358

354359
@dataclass
355360
class DAVUser:
356361
username: str
357362
password: str
358363
permissions: list[str]
364+
359365
admin: bool
366+
anonymous: bool = False
360367

361368
permissions_allow: list[str] = field(default_factory=list)
362369
permissions_deny: list[str] = field(default_factory=list)

asgi_webdav/exception.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1+
"""
2+
异常设计规则
3+
4+
- 以模块(逻辑概念的)做一级分割
5+
- 如果需要再做二级分割
6+
"""
7+
8+
19
class DAVException(Exception):
210
pass
311

412

5-
class DAVExceptionConfigPaserFailed(DAVException):
13+
class DAVExceptionConfig(DAVException):
614
pass
715

816

asgi_webdav/server.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,17 @@ async def handle(
8989
# process WebDAV request
9090
try:
9191
response = await self.web_dav.distribute(request)
92+
logger.debug(response)
9293

9394
except DAVExceptionProviderInitFailed as e:
9495
logger.critical(e)
9596
logger.info(_service_abnormal_exit_message)
9697
sys.exit(1)
9798

98-
logger.debug(response)
99+
if response.status == 401:
100+
# TODO: 临时解决方案, 重构 DAVResponse 来统一 401 的响应行为
101+
return request, self.dav_auth.create_response_401(request, "")
102+
99103
return request, response
100104

101105

asgi_webdav/web_dav.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,11 @@ async def distribute(self, request: DAVRequest) -> DAVResponse:
208208
if not request.user.check_paths_permission(paths):
209209
# not allow
210210
logger.debug(request)
211-
return DAVResponse(status=403)
211+
if request.user.anonymous:
212+
# is anonymous user
213+
return DAVResponse(status=401)
214+
else:
215+
return DAVResponse(status=403)
212216

213217
# update request distribute information
214218
request.update_distribute_info(provider.prefix)

docs/changelog.en.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
## 1.6.0
44

5-
- feat: new config, HTTPBasicAuth.cache_timeout
5+
- feat: new config, `HTTPBasicAuth.cache_timeout`
66
- Optionally expire authentication cache entries after a defined time, thanks [PIC](https://www.pic.es)
7-
- feat: new config, Compression.enable
8-
- feat: both support .toml and .json config file
9-
- feat: support optional anonymous user, thanks [PIC](https://www.pic.es)
7+
- feat: new config, `Compression.enable`
8+
- feat: both support `.toml` and `.json` config file
9+
- feat: support optional anonymous user, thanks [PIC](https://www.pic.es)'s idea
1010
- feat: Implement a WebHDFS provider, contributed by [PIC](https://www.pic.es)
1111

1212
## 1.5.0 - 20250628

0 commit comments

Comments
 (0)