feat(file): manage inactive WebDAV credentials

This commit is contained in:
kuaifan 2026-08-13 03:36:40 +00:00
parent 9028061638
commit 8133eca488
19 changed files with 229 additions and 41 deletions

View File

@ -9,6 +9,7 @@ use App\Models\WebDavLock;
use App\Module\Base;
use App\Services\WebDav\WebDavConfig;
use App\Services\WebDav\WebDavConflictService;
use App\Services\WebDav\WebDavCredentialService;
use Request;
/**
@ -151,6 +152,29 @@ class FileDavController extends AbstractController
]);
}
/**
* @api {post} api/file/dav/delete 永久删除已失效的 WebDAV 应用密码
* @apiDescription 需要token身份仅允许删除本人已撤销或已过期的凭据操作日志保留
* @apiVersion 1.0.0
* @apiGroup fileDav
* @apiName dav__delete
*
* @apiParam {Number} id 应用密码 ID
*/
public function dav__delete()
{
$user = User::auth();
$id = intval(Request::input('id'));
(new WebDavCredentialService())->deleteInactive(
$user,
$id,
Request::header('X-Request-Id'),
Request::ip(),
Request::userAgent()
);
return Base::retSuccess('删除成功', ['id' => $id]);
}
/**
* @api {get} api/file/dav/adminsetting 获取或保存 WebDAV 设置
* @apiDescription 需要管理员身份

View File

@ -0,0 +1,47 @@
<?php
namespace App\Services\WebDav;
use App\Exceptions\ApiException;
use App\Models\AbstractModel;
use App\Models\User;
use App\Models\WebDavCredential;
use App\Models\WebDavLock;
use App\Models\WebDavOperationLog;
class WebDavCredentialService
{
public function deleteInactive(
User $user,
int $credentialId,
?string $requestId,
?string $ip,
?string $userAgent
): void {
AbstractModel::transaction(function () use ($user, $credentialId, $requestId, $ip, $userAgent) {
$credential = WebDavCredential::whereUserid($user->userid)
->whereId($credentialId)
->lockForUpdate()
->first();
if (!$credential) {
throw new ApiException('WebDAV 应用密码不存在');
}
if ($credential->isActive()) {
throw new ApiException('有效的应用密码请先撤销');
}
WebDavLock::whereCredentialId($credential->id)->delete();
WebDavOperationLog::createInstance([
'request_id' => mb_substr((string) $requestId, 0, 100),
'userid' => intval($user->userid),
'credential_id' => intval($credential->id),
'method' => 'CREDENTIAL_DELETE',
'status' => 200,
'result' => mb_substr("public_id={$credential->public_id}; name={$credential->name}", 0, 255),
'ip' => mb_substr((string) $ip, 0, 45),
'user_agent' => mb_substr((string) $userAgent, 0, 255),
])->save();
$credential->delete();
});
}
}

View File

@ -66,7 +66,7 @@ WebDAV 暴露的是由 `files`、`file_contents`、`file_users` 组成的虚拟
4. 服务端返回一次性的用户名和应用密码;应用密码此后不可再次读取。
5. 页面提供服务器地址、用户名和密码字段及复制按钮,同时提示必须使用 HTTPS。
6. 用户在客户端连接后页面更新最后使用时间、IP 和客户端名称。
7. 用户可以撤销单个凭据;撤销后新请求立即失败,已有锁同步失效。
7. 用户可以撤销单个凭据;撤销后新请求立即失败,已有锁同步失效。已撤销或已过期的凭据可以永久删除,删除前保留一条不含秘密信息的操作审计;有效凭据必须先撤销。
### 3.3 文件操作闭环
@ -476,8 +476,9 @@ UNLOCK /dav/{path?}
| `api/file/dav/credentials` | GET | 登录用户 | 凭据列表,不返回哈希 |
| `api/file/dav/create` | POST | 登录用户 | 创建并一次性返回密码 |
| `api/file/dav/revoke` | POST | 登录用户 | 撤销凭据 |
| `api/file/dav/delete` | POST | 登录用户 | 永久删除本人已撤销或已过期的凭据,保留操作审计 |
这些 URL 保持 `file/{method}/{action}` 的两段动态路由限制,控制器方法分别为 `dav__adminsetting``dav__adminstatus``dav__userrevoke``dav__status``dav__credentials``dav__create``dav__revoke`
这些 URL 保持 `file/{method}/{action}` 的两段动态路由限制,控制器方法分别为 `dav__adminsetting``dav__adminstatus``dav__userrevoke``dav__status``dav__credentials``dav__create``dav__revoke``dav__delete`
路由中先将 `method = dav` 明确分派到 `FileDavController`,再让其他 `file/{method}/{action}` 进入现有 `FileController`;现有 FileController 路由应增加排除 `dav` 的约束,避免相同 URI 模式产生不确定匹配。新增控制器和路由后运行 `./cmd artisan doc:api-map`

View File

@ -1047,6 +1047,7 @@ upload_id 不能为空
未开启部门负责人视角功能
没有可查看的部门数据
WebDAV 应用密码不存在
有效的应用密码请先撤销
WebDAV 应用密码数量已达上限
WebDAV 必须通过 HTTPS 使用
WebDAV 未启用或你没有使用权限

View File

@ -39071,6 +39071,18 @@
"id": "Kata sandi aplikasi WebDAV tidak ada",
"ru": "Пароль приложения WebDAV не существует"
},
{
"key": "有效的应用密码请先撤销",
"zh": "",
"zh-CHT": "有效的應用密碼請先撤銷",
"en": "Revoke the active app password first",
"ko": "활성 앱 비밀번호를 먼저 취소하세요",
"ja": "有効なアプリパスワードを先に取り消してください",
"de": "Widerrufen Sie zuerst das aktive App-Passwort",
"fr": "Révoquez dabord le mot de passe dapplication actif",
"id": "Cabut kata sandi aplikasi yang aktif terlebih dahulu",
"ru": "Сначала отзовите активный пароль приложения"
},
{
"key": "WebDAV 应用密码数量已达上限",
"zh": "",
@ -39455,4 +39467,4 @@
"id": "Anda akan mengganti nama file pribadi milik (%T1). Tindakan ini akan langsung mengubah nama file.",
"ru": "Вы переименовываете личный файл пользователя (%T1). Это действие напрямую изменит имя файла."
}
]
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""]
["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""]

View File

@ -39,7 +39,7 @@ last_verified: v1.8.89
在操作系统或 WebDAV 客户端中添加网络位置时,服务器地址、用户名和密码必须使用弹窗给出的三项。连接采用 HTTP Basic 应用密码认证,但服务端只允许通过 HTTPS 传输。
## 管理设备
再次进入「文件」右上角「···」→「WebDAV」可查看设备名称、凭据尾号、有效期和最近使用时间。点击凭据右侧的删除图标并确认即可撤销;撤销后,使用该密码的设备会立即认证失败,未过期锁也会被清理。
再次进入「文件」右上角「···」→「WebDAV」可查看设备名称、状态、凭据尾号、有效期和最近使用时间。有效凭据可点击右侧撤销按钮;撤销后,使用该密码的设备会立即认证失败,未过期锁也会被清理。已撤销或已过期的凭据可永久删除且无法恢复;永久删除凭据不会删除既有 WebDAV 操作审计日志。
## 常见问题
- 看不到入口或提示未启用:管理员尚未开启,或当前用户不在指定成员范围内。

View File

@ -50,23 +50,29 @@
v-if="!createVisible"
type="primary"
icon="md-add"
:disabled="!status.https || credentials.length >= status.max_credentials"
:disabled="!status.https || activeCredentialCount >= status.max_credentials"
@click="createVisible=true">
{{$L('新建')}}
</Button>
</div>
<Form v-if="createVisible" class="webdav-create" @submit.native.prevent>
<FormItem :label="$L('设备名称')">
<Input v-model="createForm.name" :maxlength="100" :placeholder="$L('例如:办公室电脑')"/>
</FormItem>
<FormItem :label="$L('有效期')">
<InputNumber
v-model="createForm.expire_days"
:min="1"
:max="status.max_expire_days"/>
<span class="webdav-days">{{$L('[day_unit].天')}}</span>
</FormItem>
<div class="webdav-create-fields">
<div class="webdav-create-field">
<label>{{$L('设备名称')}}</label>
<Input v-model="createForm.name" :maxlength="100" :placeholder="$L('例如:办公室电脑')"/>
</div>
<div class="webdav-create-field">
<label>{{$L('有效期')}}</label>
<Input
v-model="createForm.expire_days"
type="number"
:min="1"
:max="status.max_expire_days">
<span slot="append">{{$L('[day_unit].天')}}</span>
</Input>
</div>
</div>
<div class="webdav-create-actions">
<Button @click="createVisible=false">{{$L('取消')}}</Button>
<Button type="primary" :loading="creating" @click="createCredential">{{$L('创建')}}</Button>
@ -76,20 +82,22 @@
<div v-if="credentials.length" class="webdav-credentials">
<div v-for="item in credentials" :key="item.id" class="webdav-credential">
<div class="webdav-credential-main">
<strong>{{item.name}}</strong>
<div class="webdav-credential-title">
<strong>{{item.name}}</strong>
<Tag :color="item.status === 'active' ? 'green' : 'default'">{{statusText(item.status)}}</Tag>
</div>
<span>{{item.public_id}} · ****{{item.password_suffix}}</span>
<span>
{{$L('有效期至')}}{{item.expires_at || $L('永久')}}
<template v-if="item.last_used_at"> · {{$L('最近使用')}}{{item.last_used_at}}</template>
</span>
</div>
<Tag :color="item.status === 'active' ? 'green' : 'default'">{{statusText(item.status)}}</Tag>
<Button
v-if="item.status === 'active'"
type="text"
icon="ios-trash-outline"
class="webdav-revoke"
@click="revokeCredential(item)"/>
<Tooltip v-if="item.status === 'active'" :content="$L('撤销')" placement="top" transfer>
<Button icon="md-close" class="webdav-credential-action" @click="revokeCredential(item)"/>
</Tooltip>
<Tooltip v-else :content="$L('删除')" placement="top" transfer>
<Button icon="ios-trash-outline" class="webdav-credential-action webdav-delete" @click="deleteCredential(item)"/>
</Tooltip>
</div>
</div>
<div v-else-if="!createVisible" class="webdav-empty">{{$L('暂无应用密码')}}</div>
@ -139,6 +147,11 @@ export default {
}
},
},
computed: {
activeCredentialCount() {
return this.credentials.filter(item => item.status === 'active').length;
},
},
methods: {
load() {
this.loading++;
@ -189,10 +202,21 @@ export default {
}).then(() => this.load()),
});
},
deleteCredential(item) {
$A.modalConfirm({
title: '删除',
content: '你确定要删除吗?',
onOk: () => this.$store.dispatch('call', {
url: 'file/dav/delete',
method: 'post',
data: {id: item.id},
}).then(() => this.load()),
});
},
statusText(status) {
if (status === 'active') return $L('[credential_status].有效');
if (status === 'expired') return $L('已过期');
if (status === 'revoked') return $L('已撤销');
if (status === 'active') return this.$L('[credential_status].有效');
if (status === 'expired') return this.$L('已过期');
if (status === 'revoked') return this.$L('已撤销');
return status;
},
},
@ -238,8 +262,20 @@ export default {
border-top: 1px solid #f0f0f0;
border-bottom: 1px solid #f0f0f0;
}
.webdav-days {
margin-left: 8px;
.webdav-create-fields {
display: grid;
grid-template-columns: minmax(0, 1fr) 120px;
gap: 16px;
margin-bottom: 16px;
}
.webdav-create-field {
min-width: 0;
}
.webdav-create-field > label {
display: block;
margin-bottom: 8px;
color: #515a6e;
line-height: 1;
}
.webdav-create-actions {
display: flex;
@ -248,9 +284,25 @@ export default {
}
.webdav-credential {
min-height: 70px;
gap: 12px;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.webdav-credential .ivu-tag {
margin: 0;
}
.webdav-credential-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.webdav-credential-title strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.webdav-credential-main {
flex: 1;
min-width: 0;
@ -265,7 +317,10 @@ export default {
font-size: 12px;
margin-top: 3px;
}
.webdav-revoke {
.webdav-credential-action {
flex: none;
}
.webdav-delete {
color: #ed4014;
}
.webdav-empty {
@ -273,4 +328,10 @@ export default {
padding: 24px 0;
text-align: center;
}
@media (max-width: 520px) {
.webdav-create-fields {
grid-template-columns: minmax(0, 1fr);
gap: 0;
}
}
</style>

View File

@ -2,7 +2,7 @@
> 此文件由 `php artisan doc:api-map` 生成,勿手改。
接口总数325
接口总数326
## 路由规则
@ -306,6 +306,7 @@ API 使用动态路由(见 `routes/web.php`URL 段映射为控制器方
| api/file/dav/credentials | dav__credentials() | get | 获取 WebDAV 凭据 |
| api/file/dav/create | dav__create() | post | 创建 WebDAV 应用密码 |
| api/file/dav/revoke | dav__revoke() | post | 撤销 WebDAV 应用密码 |
| api/file/dav/delete | dav__delete() | post | 永久删除已失效的 WebDAV 应用密码 |
| api/file/dav/adminsetting | dav__adminsetting() | get | 获取或保存 WebDAV 设置 |
| api/file/dav/adminstatus | dav__adminstatus() | get | 获取 WebDAV 运行状态 |
| api/file/dav/conflicts | dav__conflicts() | get | 获取 WebDAV 路径冲突明细 |

View File

@ -8,7 +8,9 @@ use App\Models\File;
use App\Models\FileUser;
use App\Models\User;
use App\Models\WebDavCredential;
use App\Models\WebDavLock;
use App\Models\WebDavOperationLog;
use App\Services\WebDav\WebDavCredentialService;
use App\Services\WebDav\WebDavConfig;
use App\Services\WebDav\WebDavConflictService;
use App\Services\WebDav\WebDavExceptionMapper;
@ -219,6 +221,44 @@ class WebDavContractTest extends TestCase
$this->assertStringContainsString('admin-conflict.txt -> admin-renamed.txt', (string) $log->result);
}
public function test_only_inactive_credentials_can_be_permanently_deleted_with_audit_retained(): void
{
$user = User::query()->firstOrFail();
[$credential] = WebDavCredential::issue($user, 'delete-test', 30);
$service = new WebDavCredentialService();
try {
$service->deleteInactive($user, intval($credential->id), 'delete-active', '127.0.0.1', 'phpunit');
$this->fail('Active credential deletion should be rejected.');
} catch (\App\Exceptions\ApiException $exception) {
$this->assertSame('有效的应用密码请先撤销', $exception->getMessage());
}
$credential->revoke();
WebDavLock::createInstance([
'token' => 'delete-test-lock',
'userid' => $user->userid,
'credential_id' => $credential->id,
'uri' => 'files/delete-test',
'uri_hash' => hash('sha256', 'files/delete-test'),
'scope' => 'exclusive',
'depth' => 'infinity',
'timeout_at' => now()->addHour(),
])->save();
$service->deleteInactive($user, intval($credential->id), 'delete-inactive', '127.0.0.1', 'phpunit');
$this->assertNull(WebDavCredential::find($credential->id));
$this->assertFalse(WebDavLock::whereCredentialId($credential->id)->exists());
$this->assertDatabaseHas('webdav_operation_logs', [
'request_id' => 'delete-inactive',
'userid' => $user->userid,
'credential_id' => $credential->id,
'method' => 'CREDENTIAL_DELETE',
'status' => 200,
]);
}
public function test_server_factory_registers_required_plugins(): void
{
$user = User::createInstance(['userid' => 123]);
@ -239,6 +279,7 @@ class WebDavContractTest extends TestCase
$this->assertStringContainsString('api/file/dav/status', $map);
$this->assertStringContainsString('dav__status()', $map);
$this->assertStringContainsString('api/file/dav/adminsetting', $map);
$this->assertStringContainsString('api/file/dav/delete', $map);
}
public function test_protocol_bridge_handles_sabre_null_body_as_empty_response(): void