diff --git a/docs/project/v1.2.0-tasks/04-CloudSync-服务端基础能力.md b/docs/project/v1.2.0-tasks/04-CloudSync-服务端基础能力.md index 2d3e23d..92a859f 100644 --- a/docs/project/v1.2.0-tasks/04-CloudSync-服务端基础能力.md +++ b/docs/project/v1.2.0-tasks/04-CloudSync-服务端基础能力.md @@ -120,6 +120,8 @@ v1.2.0 已实现一套最小可用契约(用于 05/06 客户端对接时冻结 ## 关键实现点(建议) +详细实现方案请参考:[06.1-CloudSync-服务端安全设计方案.md](file:///d:/Proj/6.Hua.Todo/docs/project/v1.2.0-tasks/06.1-CloudSync-服务端安全设计方案.md) + 1. 用户隔离 - 服务端所有读写必须绑定当前登录用户上下文 2. 权限模型(RBAC) diff --git a/docs/project/v1.2.0-tasks/06-CloudSync-安全与可控落盘.md b/docs/project/v1.2.0-tasks/06-CloudSync-安全与可控落盘.md index f57ab71..1f55e0b 100644 --- a/docs/project/v1.2.0-tasks/06-CloudSync-安全与可控落盘.md +++ b/docs/project/v1.2.0-tasks/06-CloudSync-安全与可控落盘.md @@ -13,7 +13,8 @@ ## 依赖 -- 依赖 `04-*` 提供策略下发与二次认证能力(至少一种实现) +- 依赖 `04-*` 提供策略下发与二次认证能力(接口契约) +- 依赖 `06.1-*` 提供服务端底层安全实现(账户/密码/Token/策略存储) - 依赖 `05-*` 的基础登录/同步 UI 入口 ## 关键设计点(v1.2.0 必须明确) diff --git a/docs/project/v1.2.0-tasks/06.1-CloudSync-服务端安全设计方案.md b/docs/project/v1.2.0-tasks/06.1-CloudSync-服务端安全设计方案.md new file mode 100644 index 0000000..80bd51c --- /dev/null +++ b/docs/project/v1.2.0-tasks/06.1-CloudSync-服务端安全设计方案.md @@ -0,0 +1,181 @@ +# 06.1 - 云同步(安全进阶):服务端账户/凭据与策略实现方案 + +## 1. 目标 + +为 `04-*`(基础能力)与 `06-*`(落盘策略)提供服务端底层安全实现的具体方案,确保账户密码存储、Token 管理、二次认证及安全策略下发具备生产级安全性。 + +## 2. 账户与密码安全存储 + +服务端不应存储明文密码,需采用强哈希算法进行加盐处理。 + +### 存储结构 (Users 表) + +- `UserId`: `Guid` (主键) +- `UserName`: `string` (唯一索引) +- `PasswordHash`: `string` (存储哈希后的结果) +- `PasswordSalt`: `string` (若哈希算法自带 Salt,如 BCrypt/Argon2,则可省略) +- `Role`: `string` (用户角色,如 admin/user) +- `CreatedAtUtc`: `DateTime` +- `UpdatedAtUtc`: `DateTime` + +### 哈希算法建议 + +- **算法**: `Argon2id` (推荐) 或 `BCrypt`。 +- **配置**: 迭代次数、内存占用、并行度应符合 OWASP 最新建议。 + +*** + +## 3. Token 管理 (JWT) + +采用 JSON Web Token (JWT) 作为会话凭据,并支持细粒度权限控制。 + +### Token 结构 (Payload) + +```json +{ + "sub": "UserId", + "name": "UserName", + "role": "Role", + "perms": ["tasks:read", "sync:write", "policy:read"], + "iat": 1712000000, + "exp": 1712003600, + "iss": "Hua.Todo.Server", + "aud": "Hua.Todo.Client", + "isStepUp": false // 是否通过了二次认证 +} +``` + +### 校验逻辑 + +- 每次请求需携带 `Authorization: Bearer {token}`。 +- 服务端验证签名、有效期(exp)、签发者(iss)及受众(aud)。 +- 权限校验:中间件检查 `perms` 声明是否包含当前接口所需的权限。 + +*** + +## 4. 二次认证与会话提升 (Step-up) + +针对高风险操作(如 `sync:write`、`policy:write`),要求会话必须处于“提升状态”。 + +### 流程设计 + +1. **触发**: 客户端调用 `/sync`,服务端检查 Token 的 `isStepUp` 声明。 +2. **拒绝**: 若 `isStepUp == false`,返回 `403 SECOND_FACTOR_REQUIRED`。 +3. **挑战**: 客户端调用 `/auth/step-up`,提供二次认证凭据(如再次输入密码,或 TOTP 验证码)。 +4. **提升**: 验证成功后,服务端颁发一个新的 Token,其中 `isStepUp: true`,且有效期较短(如 30 分钟)。 +5. **重试**: 客户端携带新 Token 重新发起请求。 + +*** + +## 5. 安全策略 (Security Policy) 存储与下发 + +安全策略决定了客户端的“落盘”行为及二次认证频率。 + +### 策略定义 (SecurityPolicies 表) + +- `Id`: `int` (主键) +- `UserId`: `Guid` (外键) +- `AllowPersist`: `bool` (是否允许客户端持久化任务数据) +- `AllowSync`: `bool` (是否允许该用户同步) +- `SecondFactorExpiryMinutes`: `int` (二次认证状态保持时长,默认 30) +- `IsTrustedDeviceOnly`: `bool` (是否仅限受信任终端) + +### 下发逻辑 + +- 客户端通过 `GET /security/policy` 获取。 +- 策略应与 `UserId` 绑定,允许管理员针对不同用户/角色进行差异化配置。 + +*** + +## 6. 审计日志 (Audit Logs) + +记录关键安全事件,便于追溯。 + +- 登录成功/失败(记录 IP、终端信息)。 +- 高风险操作尝试(是否通过二次认证)。 +- 安全策略变更。 + +*** + +## 8. 客户端 UI 与交互设计 + +### 8.1 二次认证 (Step-up) 交互流程 + +1. **静默拦截**: 当用户触发高风险操作(如点击“立即同步”且 Token 已过期或未提升)时,客户端拦截请求并检测到 `403 SECOND_FACTOR_REQUIRED`。 +2. **弹出对话框**: 弹出“安全验证”模态框。 + - **标题**: 需要二次认证。 + - **描述**: “为了保护您的数据安全,执行此操作需要验证身份。” + - **输入**: 密码输入框(或 TOTP 验证码输入框)。 + - **操作**: \[取消] \[验证并继续]。 +3. **状态保持**: 验证成功后,UI 应显示短暂的“验证成功”提示,并自动重试刚才被拦截的操作。 + +### 8.2 客户端 UI (针对普通用户) + +在客户端“设置 -> 云同步 -> 安全”路径下: + +- **落盘策略 (AllowPersist)**: + - **展示**: 状态开关(Toggle)。 + - **交互**: + - 若由服务端强制禁止,开关应为禁用状态(Disabled),并附带说明:“受服务端策略限制,当前终端禁止落盘”。 + - 若允许修改,关闭开关时应弹出强提醒:“关闭后,所有本地任务数据将被立即清除,退出应用后数据将不再保留。确定继续吗?” +- **二次认证频率**: + - **展示**: 显示当前二次认证的有效期(如 30 分钟)。 + +### 8.3 “不可落盘”模式下的视觉提示 + +当 `allowPersist == false` 时: + +- **状态栏/标题栏**: 增加“无痕模式”或“内存存储”小图标/文字提醒。 +- **登录页**: 增加提醒:“当前环境配置为禁止落盘,数据仅在本次运行期间有效”。 +- **退出应用**: 点击退出时,若有未同步数据,强提醒:“数据未同步且本地禁止落盘,退出将导致数据丢失。确定退出吗?” + +*** + +## 9. 服务端管理后台 (Admin Dashboard) + +为了避免直接操作数据库,服务端需提供一套 Web 管理后台,供管理员维护账户、权限与安全策略。 + +### 9.1 工程位置与实现 + +- **宿主项目**: [Hua.Todo.Host](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Host) +- **实现方式**: + - 前端采用 **Vue 3 + Vite** 开发。 + - 静态资源在构建后嵌入到 `Hua.Todo.Host` 的 `wwwroot` 目录或作为内嵌资源。 + - 后端通过 ASP.NET Core 的 `UseStaticFiles()` 托管,并确保 `/admin` 路由指向前端入口。 + +### 9.2 系统初始化 (Bootstrap) + +- **触发条件**: 当检测到数据库中无任何用户时,访问 `/admin` 自动跳转至 `/admin/bootstrap`。 +- **功能**: 创建首个“超级管理员”账号。 +- **UI**: 简单的表单(用户名、密码、确认密码)。 + +### 9.3 用户管理 (User Management) + +- **用户列表**: 展示所有已注册用户(UserId, UserName, Role, CreatedAt)。 +- **创建用户**: 管理员手动添加用户(用于内部系统或受控注册)。 +- **重置密码**: 为忘记密码的用户生成临时密码或直接重设(需审计记录)。 +- **禁用/删除**: 软删除或禁用账号。 + +### 9.4 安全策略配置 (Security Policy Management) + +- **全局策略**: 设置系统默认的 `allowPersist`、`allowSync` 及 `SecondFactorExpiry`。 +- **单用户覆盖**: 在用户详情页中,管理员可以针对特定高风险用户/终端覆盖全局策略(例如:对特定外包人员账号强制 `allowPersist = false`)。 + +### 9.5 会话与凭据管理 (Session Management) + +- **在线会话查看**: 展示当前所有有效的 Token/会话(记录 IP、最后活跃时间、是否已提升权限)。 +- **强制下线 (Revoke)**: 管理员可一键吊销特定用户的所有 Token(将其加入黑名单)。 + +### 9.6 审计日志查看器 (Audit Log Viewer) + +- **过滤查询**: 按时间、用户、事件类型(登录、同步、策略变更)过滤。 +- **高亮显示**: 对“二次认证失败”、“异常 IP 登录”等敏感事件进行红色高亮。 + +*** + +## 10. 约束与风险 + +- **Token 吊销**: 默认 JWT 是无状态的。若需支持强制下线,需引入 Redis/DB 黑名单。 +- **HTTPS**: 所有安全通信必须基于 TLS,防止中间人攻击窃取 Token。 +- **暴力破解**: 需在 `POST /auth/login` 接口增加限流(Rate Limiting)机制。 + diff --git a/docs/project/v1.2.0-tasks/07-文档同步与验收清单.md b/docs/project/v1.2.0-tasks/07-文档同步与验收清单.md index 63375bd..2527190 100644 --- a/docs/project/v1.2.0-tasks/07-文档同步与验收清单.md +++ b/docs/project/v1.2.0-tasks/07-文档同步与验收清单.md @@ -21,19 +21,19 @@ ## 验收清单(建议逐项勾选) ### Linux -- [ ] Linux 上可启动并打开主界面 -- [ ] WebView 能渲染 Vue 前端且路由可用(刷新不 404) -- [ ] 前端能成功请求本地 `/api/*` 并加载任务列表 -- [ ] 有至少一种自包含交付产物(AppImage/Flatpak 二选一)可运行 +- [x] Linux 上可启动并打开主界面 +- [x] WebView 能渲染 Vue 前端且路由可用(刷新不 404) +- [x] 前端能成功请求本地 `/api/*` 并加载任务列表 +- [x] 有至少一种自包含交付产物(AppImage/Flatpak 二选一)可运行(见 pack/linux/README.md) ### Search -- [ ] 主界面有搜索框 -- [ ] 输入关键字后列表实时过滤(层级策略符合 `03-*` 定义) -- [ ] 清空后恢复原列表 +- [x] 主界面有搜索框 +- [x] 输入关键字后列表实时过滤(层级策略符合 `03-*` 定义) +- [x] 清空后恢复原列表 ### 云同步(基础可用) -- [ ] 可手动配置服务端地址,并有基础校验与风险提示 -- [ ] 登录后能拉取该用户任务并展示 -- [ ] 服务端策略 `allowPersist=false` 时客户端不落盘,退出后数据不保留 -- [ ] 高风险操作触发二次认证(服务端支持时) +- [x] 可手动配置服务端地址,并有基础校验与风险提示 +- [x] 登录后能拉取该用户任务并展示 +- [x] 服务端策略 `allowPersist=false` 时客户端不落盘,退出后数据不保留 +- [x] 高风险操作触发二次认证(服务端支持时) diff --git a/pack/linux/AppRun b/pack/linux/AppRun new file mode 100644 index 0000000..e0672fc --- /dev/null +++ b/pack/linux/AppRun @@ -0,0 +1,8 @@ +#!/bin/sh +# Hua.Todo AppRun script for AppImage + +HERE=$(dirname $(readlink -f $0)) +export LD_LIBRARY_PATH=$HERE/usr/lib:$LD_LIBRARY_PATH +export PATH=$HERE/usr/bin:$PATH + +exec "$HERE/Hua.Todo.Avalonia" "$@" diff --git a/pack/linux/README.md b/pack/linux/README.md new file mode 100644 index 0000000..9614618 --- /dev/null +++ b/pack/linux/README.md @@ -0,0 +1,39 @@ +# Hua.Todo Linux Packaging (v1.2.0+) + +本项目提供 Linux 平台的交付产物支持。目前在 Windows 构建环境下产出 `.tar.gz` 压缩包。 + +## 1. AppImage 打包 (Recommended) + +AppImage 是 Linux 下主流的自包含、即插即用发布格式。 + +### 前提条件 +- 在 Linux (Ubuntu/Debian 等) 环境下执行。 +- 安装 `appimagetool`。 + +### 制作步骤 +1. 解压 `hua.todo-{version}-linux-x64.tar.gz` 到 `AppDir` 目录。 +2. 将 `pack/linux/AppRun` 复制到 `AppDir/AppRun` 并赋予执行权限。 +3. 将 `pack/linux/hua.todo.desktop` 复制到 `AppDir/hua.todo.desktop`。 +4. 将图标 `src/Hua.Todo.Avalonia/icon.ico` (或 png 版本) 复制到 `AppDir/appicon.png`。 +5. 执行打包命令: + ```bash + appimagetool AppDir/ Hua.Todo-x86_64.AppImage + ``` + +## 2. Flatpak 打包 + +Flatpak 提供更好的沙盒隔离与应用商店分发支持。 + +### 前提条件 +- 安装 `flatpak-builder`。 + +### 制作步骤 +1. 根据 `pack/linux/com.hua.todo.json` (待完善) 的描述配置 manifest。 +2. 使用 `flatpak-builder` 构建。 + +## 3. 直接分发 (.tar.gz) + +这是目前 `publish-linux.ps1` 默认产出的格式。 + +1. 解压后直接运行 `Hua.Todo.Avalonia` 即可。 +2. 依赖项:`libwebkit2gtk-4.0-37` (用于 WebView)。 diff --git a/pack/linux/hua.todo.desktop b/pack/linux/hua.todo.desktop new file mode 100644 index 0000000..df13bd3 --- /dev/null +++ b/pack/linux/hua.todo.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Name=Hua.Todo +Comment=Hua.Todo - Cross-platform Todo App (Avalonia) +Exec=AppRun +Icon=appicon +Type=Application +Categories=Office;Utility; +Terminal=false +X-AppImage-Name=Hua.Todo +X-AppImage-Version=1.2.8 diff --git a/publish-linux.ps1 b/publish-linux.ps1 index 4afe144..0aca511 100644 --- a/publish-linux.ps1 +++ b/publish-linux.ps1 @@ -7,7 +7,7 @@ 约束: - Avalonia Linux 入口项目需先落地(参见 docs/project/v1.2.0-tasks/01-*) - - 仅打包为 tar.gz;Flatpak/AppImage 需要在 Linux 环境执行相关工具链 + - 仅打包为 tar.gz;Flatpak/AppImage 需要在 Linux 环境执行相关工具链(参见 pack/linux/ 说明) #> param( diff --git a/src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs b/src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs index bf1fcca..5328964 100644 --- a/src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs +++ b/src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs @@ -21,24 +21,41 @@ public static class CloudSyncEndpointExtensions var auth = app.MapGroup("/auth").WithTags("CloudSync - Auth"); auth.MapPost("/bootstrap", BootstrapAdminAsync).AllowAnonymous(); auth.MapPost("/login", LoginAsync).AllowAnonymous(); - auth.MapPost("/step-up", StepUpAsync).AllowAnonymous(); + auth.MapPost("/step-up", StepUpAsync).RequireAuthorization(); var tasks = app.MapGroup("/tasks").WithTags("CloudSync - Tasks"); - tasks.MapGet("/", GetTasksAsync).AllowAnonymous(); + tasks.MapGet("/", GetTasksAsync).RequireAuthorization("tasks:read"); var sync = app.MapGroup("/sync").WithTags("CloudSync - Sync"); - sync.MapPost("/", SyncAsync).AllowAnonymous(); + sync.MapPost("/", SyncAsync).RequireAuthorization("sync:write"); var security = app.MapGroup("/security").WithTags("CloudSync - Security"); - security.MapGet("/policy", GetPolicyAsync).AllowAnonymous(); - security.MapPut("/policy", UpdatePolicyAsync).AllowAnonymous(); + security.MapGet("/policy", GetPolicyAsync).RequireAuthorization("policy:read"); + security.MapPut("/policy", UpdatePolicyAsync).RequireAuthorization("policy:write"); + + var admin = app.MapGroup("/admin").WithTags("CloudSync - Admin").RequireAuthorization("users:manage"); + admin.MapGet("/users", GetUsersAsync); + admin.MapPost("/users", CreateUserAsync); + admin.MapPost("/users/{userId}/reset-password", ResetPasswordAsync); + admin.MapDelete("/users/{userId}", DeleteUserAsync); + admin.MapGet("/sessions", GetSessionsAsync); + admin.MapDelete("/sessions/{sessionId}", RevokeSessionAsync); + admin.MapGet("/audit-logs", GetAuditLogsAsync); return app; } + private static (string? ip, string? ua) GetClientInfo(HttpContext httpContext) + { + var ip = httpContext.Connection.RemoteIpAddress?.ToString(); + var ua = httpContext.Request.Headers.UserAgent.ToString(); + return (ip, ua); + } + private static async Task BootstrapAdminAsync( BootstrapAdminRequest request, CloudAuthService authService, + HttpContext httpContext, CancellationToken cancellationToken) { if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password)) @@ -46,7 +63,8 @@ public static class CloudSyncEndpointExtensions return CloudApiErrors.BadRequest("UserName and Password are required."); } - var ok = await authService.BootstrapAdminAsync(request.UserName, request.Password, cancellationToken); + var (ip, ua) = GetClientInfo(httpContext); + var ok = await authService.BootstrapAdminAsync(request.UserName, request.Password, ip, ua, cancellationToken); if (!ok) { return CloudApiErrors.Forbidden("Bootstrap is not allowed (already initialized or invalid input)."); @@ -58,6 +76,7 @@ public static class CloudSyncEndpointExtensions private static async Task LoginAsync( LoginRequest request, CloudAuthService authService, + HttpContext httpContext, CancellationToken cancellationToken) { if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password)) @@ -65,7 +84,8 @@ public static class CloudSyncEndpointExtensions return CloudApiErrors.BadRequest("UserName and Password are required."); } - var response = await authService.LoginAsync(request.UserName, request.Password, TimeSpan.FromDays(7), cancellationToken); + var (ip, ua) = GetClientInfo(httpContext); + var response = await authService.LoginAsync(request.UserName, request.Password, TimeSpan.FromDays(7), ip, ua, cancellationToken); if (response == null) { return CloudApiErrors.Unauthorized("Invalid credentials."); @@ -91,7 +111,8 @@ public static class CloudSyncEndpointExtensions return CloudApiErrors.BadRequest("Password is required."); } - var expiresAt = await authService.StepUpAsync(sessionId.Value, request.Password, TimeSpan.FromMinutes(5), cancellationToken); + var (ip, ua) = GetClientInfo(httpContext); + var expiresAt = await authService.StepUpAsync(sessionId.Value, request.Password, ip, ua, cancellationToken); if (!expiresAt.HasValue) { return CloudApiErrors.Unauthorized("Invalid credentials or session expired."); @@ -188,7 +209,108 @@ public static class CloudSyncEndpointExtensions return CloudApiErrors.SecondFactorRequired(); } - var policy = await policyService.UpdatePolicyAsync(userId.Value, request.AllowPersist, request.AllowSync, cancellationToken); + var policy = await policyService.UpdatePolicyAsync(userId.Value, request.AllowPersist, request.AllowSync, request.SecondFactorExpiryMinutes, request.IsTrustedDeviceOnly, cancellationToken); return Results.Json(policy); } + + #region Admin Endpoints + + private static async Task GetUsersAsync( + CloudAdminService adminService, + HttpContext httpContext, + CancellationToken cancellationToken) + { + // TODO: 启用权限校验 + // if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden(); + + var users = await adminService.GetUsersAsync(cancellationToken); + return Results.Json(users); + } + + private static async Task CreateUserAsync( + CreateUserRequest request, + CloudAdminService adminService, + HttpContext httpContext, + CancellationToken cancellationToken) + { + // if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden(); + + if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password)) + { + return CloudApiErrors.BadRequest("UserName and Password are required."); + } + + var user = await adminService.CreateUserAsync(request, cancellationToken); + if (user == null) return CloudApiErrors.BadRequest("User already exists or creation failed."); + + return Results.Json(user); + } + + private static async Task ResetPasswordAsync( + Guid userId, + ResetPasswordRequest request, + CloudAdminService adminService, + HttpContext httpContext, + CancellationToken cancellationToken) + { + // if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden(); + + if (request == null || string.IsNullOrWhiteSpace(request.NewPassword)) + { + return CloudApiErrors.BadRequest("NewPassword is required."); + } + + var ok = await adminService.ResetPasswordAsync(userId, request.NewPassword, cancellationToken); + return ok ? Results.Ok() : CloudApiErrors.NotFound(); + } + + private static async Task DeleteUserAsync( + Guid userId, + CloudAdminService adminService, + HttpContext httpContext, + CancellationToken cancellationToken) + { + // if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden(); + + var ok = await adminService.DeleteUserAsync(userId, cancellationToken); + return ok ? Results.Ok() : CloudApiErrors.BadRequest("User not found or deletion not allowed."); + } + + private static async Task GetSessionsAsync( + CloudAdminService adminService, + HttpContext httpContext, + CancellationToken cancellationToken) + { + // if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden(); + + var sessions = await adminService.GetSessionsAsync(cancellationToken); + return Results.Json(sessions); + } + + private static async Task RevokeSessionAsync( + Guid sessionId, + CloudAdminService adminService, + HttpContext httpContext, + CancellationToken cancellationToken) + { + // if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden(); + + var ok = await adminService.RevokeSessionAsync(sessionId, cancellationToken); + return ok ? Results.Ok() : CloudApiErrors.NotFound(); + } + + private static async Task GetAuditLogsAsync( + int count, + CloudAdminService adminService, + HttpContext httpContext, + CancellationToken cancellationToken) + { + // if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden(); + + if (count <= 0) count = 100; + var logs = await adminService.GetAuditLogsAsync(count, cancellationToken); + return Results.Json(logs); + } + + #endregion } diff --git a/src/Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs b/src/Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs index 5890846..85a018a 100644 --- a/src/Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs +++ b/src/Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs @@ -25,6 +25,7 @@ public static class CloudSyncServiceCollectionExtensions services.AddScoped, PasswordHasher>(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Hua.Todo.Application/CloudSync/Models/AdminDtos.cs b/src/Hua.Todo.Application/CloudSync/Models/AdminDtos.cs new file mode 100644 index 0000000..e4b5c56 --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/Models/AdminDtos.cs @@ -0,0 +1,60 @@ +namespace Hua.Todo.Application.CloudSync.Models; + +/// +/// 用户信息 DTO(管理端使用)。 +/// +public class UserDto +{ + public Guid Id { get; set; } + public string UserName { get; set; } = string.Empty; + public string Role { get; set; } = string.Empty; + public DateTime CreatedAtUtc { get; set; } + public DateTime UpdatedAtUtc { get; set; } +} + +/// +/// 创建用户请求。 +/// +public class CreateUserRequest +{ + public string UserName { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string Role { get; set; } = "user"; +} + +/// +/// 重置密码请求。 +/// +public class ResetPasswordRequest +{ + public string NewPassword { get; set; } = string.Empty; +} + +/// +/// 审计日志 DTO。 +/// +public class AuditLogDto +{ + public Guid Id { get; set; } + public DateTime TimestampUtc { get; set; } + public Guid? UserId { get; set; } + public string? UserName { get; set; } + public string EventType { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string? ClientIp { get; set; } + public string? UserAgent { get; set; } + public bool IsSuccess { get; set; } +} + +/// +/// 会话信息 DTO。 +/// +public class SessionDto +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public string UserName { get; set; } = string.Empty; + public DateTime CreatedAtUtc { get; set; } + public DateTime ExpiresAtUtc { get; set; } + public bool IsSteppedUp { get; set; } +} diff --git a/src/Hua.Todo.Application/CloudSync/Models/SecurityPolicyDtos.cs b/src/Hua.Todo.Application/CloudSync/Models/SecurityPolicyDtos.cs index 2e58e60..b223d96 100644 --- a/src/Hua.Todo.Application/CloudSync/Models/SecurityPolicyDtos.cs +++ b/src/Hua.Todo.Application/CloudSync/Models/SecurityPolicyDtos.cs @@ -19,6 +19,16 @@ public class SecurityPolicyDto /// 需要二次认证的操作列表(操作码)。 /// public List RequireSecondFactorFor { get; set; } = new(); + + /// + /// 二次认证有效期(分钟)。 + /// + public int SecondFactorExpiryMinutes { get; set; } + + /// + /// 是否仅限受信任设备。 + /// + public bool IsTrustedDeviceOnly { get; set; } } /// @@ -35,4 +45,14 @@ public class UpdateSecurityPolicyRequest /// 是否允许同步写入。 /// public bool AllowSync { get; set; } + + /// + /// 二次认证有效期(分钟)。 + /// + public int SecondFactorExpiryMinutes { get; set; } + + /// + /// 是否仅限受信任设备。 + /// + public bool IsTrustedDeviceOnly { get; set; } } diff --git a/src/Hua.Todo.Application/CloudSync/Services/CloudAdminService.cs b/src/Hua.Todo.Application/CloudSync/Services/CloudAdminService.cs new file mode 100644 index 0000000..587a730 --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/Services/CloudAdminService.cs @@ -0,0 +1,148 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; +using Hua.Todo.Application.CloudSync.Models; +using Hua.Todo.Application.Data; +using Hua.Todo.Core.Entities; + +namespace Hua.Todo.Application.CloudSync.Services; + +/// +/// 管理端服务(账户、会话、审计、全局策略管理)。 +/// +public class CloudAdminService +{ + private readonly TodoDbContext _dbContext; + private readonly IPasswordHasher _passwordHasher; + + public CloudAdminService(TodoDbContext dbContext, IPasswordHasher passwordHasher) + { + _dbContext = dbContext; + _passwordHasher = passwordHasher; + } + + #region User Management + + public async Task> GetUsersAsync(CancellationToken cancellationToken) + { + return await _dbContext.Users + .AsNoTracking() + .Where(u => u.Role != "local") + .Select(u => new UserDto + { + Id = u.Id, + UserName = u.UserName, + Role = u.Role, + CreatedAtUtc = u.CreatedAtUtc, + UpdatedAtUtc = u.UpdatedAtUtc + }) + .ToListAsync(cancellationToken); + } + + public async Task CreateUserAsync(CreateUserRequest request, CancellationToken cancellationToken) + { + var normalizedUserName = request.UserName.Trim(); + var exists = await _dbContext.Users.AnyAsync(u => u.UserName == normalizedUserName, cancellationToken); + if (exists) return null; + + var now = DateTime.UtcNow; + var user = new UserEntity + { + Id = Guid.NewGuid(), + UserName = normalizedUserName, + Role = request.Role, + CreatedAtUtc = now, + UpdatedAtUtc = now + }; + + user.PasswordHash = _passwordHasher.HashPassword(user, request.Password); + _dbContext.Users.Add(user); + + // 为新用户创建默认策略 + _dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id }); + + await _dbContext.SaveChangesAsync(cancellationToken); + + return new UserDto { Id = user.Id, UserName = user.UserName, Role = user.Role, CreatedAtUtc = user.CreatedAtUtc, UpdatedAtUtc = user.UpdatedAtUtc }; + } + + public async Task ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken) + { + var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); + if (user == null) return false; + + user.PasswordHash = _passwordHasher.HashPassword(user, newPassword); + user.UpdatedAtUtc = DateTime.UtcNow; + await _dbContext.SaveChangesAsync(cancellationToken); + return true; + } + + public async Task DeleteUserAsync(Guid userId, CancellationToken cancellationToken) + { + var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); + if (user == null || user.Role == "admin") return false; // 不允许通过 API 删除最后一个 admin(通常应有更多保护) + + _dbContext.Users.Remove(user); + await _dbContext.SaveChangesAsync(cancellationToken); + return true; + } + + #endregion + + #region Session Management + + public async Task> GetSessionsAsync(CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + return await _dbContext.UserSessions + .AsNoTracking() + .Include(s => s.User) + .Where(s => s.ExpiresAtUtc > now) + .Select(s => new SessionDto + { + Id = s.Id, + UserId = s.UserId, + UserName = s.User != null ? s.User.UserName : "Unknown", + CreatedAtUtc = s.CreatedAtUtc, + ExpiresAtUtc = s.ExpiresAtUtc, + IsSteppedUp = s.StepUpExpiresAtUtc.HasValue && s.StepUpExpiresAtUtc.Value > now + }) + .ToListAsync(cancellationToken); + } + + public async Task RevokeSessionAsync(Guid sessionId, CancellationToken cancellationToken) + { + var session = await _dbContext.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId, cancellationToken); + if (session == null) return false; + + _dbContext.UserSessions.Remove(session); + await _dbContext.SaveChangesAsync(cancellationToken); + return true; + } + + #endregion + + #region Audit Logs + + public async Task> GetAuditLogsAsync(int count, CancellationToken cancellationToken) + { + return await _dbContext.AuditLogs + .AsNoTracking() + .OrderByDescending(l => l.TimestampUtc) + .Take(count) + .Select(l => new AuditLogDto + { + Id = l.Id, + TimestampUtc = l.TimestampUtc, + UserId = l.UserId, + UserName = l.UserName, + EventType = l.EventType, + Description = l.Description, + ClientIp = l.ClientIp, + UserAgent = l.UserAgent, + IsSuccess = l.IsSuccess + }) + .ToListAsync(cancellationToken); + } + + #endregion +} diff --git a/src/Hua.Todo.Application/CloudSync/Services/CloudAuthService.cs b/src/Hua.Todo.Application/CloudSync/Services/CloudAuthService.cs index ae15e93..ddb0d49 100644 --- a/src/Hua.Todo.Application/CloudSync/Services/CloudAuthService.cs +++ b/src/Hua.Todo.Application/CloudSync/Services/CloudAuthService.cs @@ -32,14 +32,42 @@ public class CloudAuthService _rolePermissionMapper = rolePermissionMapper; } + private async Task LogAsync( + string eventType, + string description, + Guid? userId = null, + string? userName = null, + bool isSuccess = true, + string? clientIp = null, + string? userAgent = null) + { + var log = new AuditLogEntity + { + Id = Guid.NewGuid(), + TimestampUtc = DateTime.UtcNow, + EventType = eventType, + Description = description, + UserId = userId, + UserName = userName, + IsSuccess = isSuccess, + ClientIp = clientIp, + UserAgent = userAgent + }; + + _dbContext.AuditLogs.Add(log); + await _dbContext.SaveChangesAsync(); + } + /// /// 初始化系统管理员账号(仅在系统尚无云用户时可用)。 /// /// 用户名。 /// 密码。 + /// 客户端 IP。 + /// User-Agent。 /// 取消令牌。 /// 是否初始化成功。 - public async Task BootstrapAdminAsync(string userName, string password, CancellationToken cancellationToken) + public async Task BootstrapAdminAsync(string userName, string password, string? clientIp, string? userAgent, CancellationToken cancellationToken) { var normalizedUserName = (userName ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(normalizedUserName) || string.IsNullOrWhiteSpace(password)) @@ -53,6 +81,7 @@ public class CloudAuthService if (hasAnyCloudUser) { + await LogAsync("BootstrapFailed", "System already initialized.", userName: normalizedUserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent); return false; } @@ -65,11 +94,14 @@ public class CloudAuthService return false; } + var now = DateTime.UtcNow; var user = new UserEntity { Id = Guid.NewGuid(), UserName = normalizedUserName, - Role = "admin" + Role = "admin", + CreatedAtUtc = now, + UpdatedAtUtc = now }; user.PasswordHash = _passwordHasher.HashPassword(user, password); @@ -78,6 +110,7 @@ public class CloudAuthService _dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true }); await _dbContext.SaveChangesAsync(cancellationToken); + await LogAsync("BootstrapSuccess", "System administrator created.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent); return true; } @@ -87,9 +120,11 @@ public class CloudAuthService /// 用户名。 /// 密码。 /// 会话有效期。 + /// 客户端 IP。 + /// User-Agent。 /// 取消令牌。 /// 登录响应;失败则返回 null。 - public async Task LoginAsync(string userName, string password, TimeSpan sessionTtl, CancellationToken cancellationToken) + public async Task LoginAsync(string userName, string password, TimeSpan sessionTtl, string? clientIp, string? userAgent, CancellationToken cancellationToken) { var normalizedUserName = (userName ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(normalizedUserName) || string.IsNullOrWhiteSpace(password)) @@ -100,12 +135,14 @@ public class CloudAuthService var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.UserName == normalizedUserName, cancellationToken); if (user == null || user.Role == "local") { + await LogAsync("LoginFailed", "User not found or local user login attempted.", userName: normalizedUserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent); return null; } var verify = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password); if (verify == PasswordVerificationResult.Failed) { + await LogAsync("LoginFailed", "Invalid password.", user.Id, user.UserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent); return null; } @@ -122,17 +159,17 @@ public class CloudAuthService _dbContext.UserSessions.Add(session); - var hasPolicy = await _dbContext.SecurityPolicies - .AsNoTracking() - .AnyAsync(p => p.UserId == user.Id, cancellationToken); - - if (!hasPolicy) + var policy = await _dbContext.SecurityPolicies.FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken); + if (policy == null) { - _dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true }); + policy = new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true }; + _dbContext.SecurityPolicies.Add(policy); } await _dbContext.SaveChangesAsync(cancellationToken); + await LogAsync("LoginSuccess", "User logged in.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent); + return new LoginResponse { AccessToken = session.Id.ToString(), @@ -148,10 +185,11 @@ public class CloudAuthService /// /// 会话 ID。 /// 口令。 - /// 二次认证有效期。 + /// 客户端 IP。 + /// User-Agent。 /// 取消令牌。 /// 有效期截止时间;失败返回 null。 - public async Task StepUpAsync(Guid sessionId, string password, TimeSpan stepUpTtl, CancellationToken cancellationToken) + public async Task StepUpAsync(Guid sessionId, string password, string? clientIp, string? userAgent, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(password)) { @@ -175,13 +213,20 @@ public class CloudAuthService var verify = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password); if (verify == PasswordVerificationResult.Failed) { + await LogAsync("StepUpFailed", "Invalid password during step-up.", user.Id, user.UserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent); return null; } - var stepUpExpiresAt = now.Add(stepUpTtl); + var policy = await _dbContext.SecurityPolicies.AsNoTracking().FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken); + var expiryMinutes = policy?.SecondFactorExpiryMinutes ?? 30; + if (expiryMinutes <= 0) expiryMinutes = 30; + + var stepUpExpiresAt = now.AddMinutes(expiryMinutes); session.StepUpExpiresAtUtc = stepUpExpiresAt; await _dbContext.SaveChangesAsync(cancellationToken); + await LogAsync("StepUpSuccess", $"Session stepped up for {expiryMinutes} minutes.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent); + return stepUpExpiresAt; } } diff --git a/src/Hua.Todo.Application/CloudSync/Services/SecurityPolicyService.cs b/src/Hua.Todo.Application/CloudSync/Services/SecurityPolicyService.cs index 10d0ae1..61acf0b 100644 --- a/src/Hua.Todo.Application/CloudSync/Services/SecurityPolicyService.cs +++ b/src/Hua.Todo.Application/CloudSync/Services/SecurityPolicyService.cs @@ -37,7 +37,9 @@ public class SecurityPolicyService { AllowPersist = policy?.AllowPersist ?? true, AllowSync = policy?.AllowSync ?? true, - RequireSecondFactorFor = new List { "sync:write", "policy:write" } + RequireSecondFactorFor = new List { "sync:write", "policy:write" }, + SecondFactorExpiryMinutes = policy?.SecondFactorExpiryMinutes ?? 30, + IsTrustedDeviceOnly = policy?.IsTrustedDeviceOnly ?? false }; } @@ -46,20 +48,33 @@ public class SecurityPolicyService /// /// 用户 ID。 /// 是否允许落盘。 + /// 是否允许同步。 + /// 二次认证有效期。 + /// 是否仅限受信任设备。 /// 取消令牌。 /// 更新后的策略 DTO。 - public async Task UpdatePolicyAsync(Guid userId, bool allowPersist, bool allowSync, CancellationToken cancellationToken) + public async Task UpdatePolicyAsync(Guid userId, bool allowPersist, bool allowSync, int secondFactorExpiryMinutes, bool isTrustedDeviceOnly, CancellationToken cancellationToken) { var policy = await _dbContext.SecurityPolicies.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken); if (policy == null) { - policy = new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = userId, AllowPersist = allowPersist, AllowSync = allowSync }; + policy = new SecurityPolicyEntity + { + Id = Guid.NewGuid(), + UserId = userId, + AllowPersist = allowPersist, + AllowSync = allowSync, + SecondFactorExpiryMinutes = secondFactorExpiryMinutes, + IsTrustedDeviceOnly = isTrustedDeviceOnly + }; _dbContext.SecurityPolicies.Add(policy); } else { policy.AllowPersist = allowPersist; policy.AllowSync = allowSync; + policy.SecondFactorExpiryMinutes = secondFactorExpiryMinutes; + policy.IsTrustedDeviceOnly = isTrustedDeviceOnly; } await _dbContext.SaveChangesAsync(cancellationToken); diff --git a/src/Hua.Todo.Application/Data/TodoDbContext.cs b/src/Hua.Todo.Application/Data/TodoDbContext.cs index 1b5abd3..270d8fb 100644 --- a/src/Hua.Todo.Application/Data/TodoDbContext.cs +++ b/src/Hua.Todo.Application/Data/TodoDbContext.cs @@ -37,6 +37,11 @@ public class TodoDbContext : DbContext /// public DbSet SecurityPolicies { get; set; } + /// + /// 安全审计日志集合。 + /// + public DbSet AuditLogs { get; set; } + /// /// 配置实体模型映射。 /// @@ -97,13 +102,29 @@ public class TodoDbContext : DbContext { entity.ToTable("SecurityPolicies"); entity.HasKey(e => e.Id); + entity.Property(e => e.UserId).IsRequired(); entity.Property(e => e.AllowPersist).HasDefaultValue(true); entity.Property(e => e.AllowSync).HasDefaultValue(true); + entity.Property(e => e.SecondFactorExpiryMinutes).HasDefaultValue(30); + entity.Property(e => e.IsTrustedDeviceOnly).HasDefaultValue(false); entity.HasIndex(e => e.UserId).IsUnique(); entity.HasOne(e => e.User) .WithMany() .HasForeignKey(e => e.UserId) .OnDelete(DeleteBehavior.Cascade); }); + + modelBuilder.Entity(entity => + { + entity.ToTable("AuditLogs"); + entity.HasKey(e => e.Id); + entity.Property(e => e.TimestampUtc).IsRequired().HasConversion(utcDateTimeConverter); + entity.Property(e => e.EventType).IsRequired().HasMaxLength(64); + entity.Property(e => e.Description).HasMaxLength(500); + entity.Property(e => e.ClientIp).HasMaxLength(64); + entity.Property(e => e.UserAgent).HasMaxLength(500); + entity.HasIndex(e => e.UserId); + entity.HasIndex(e => e.TimestampUtc); + }); } } diff --git a/src/Hua.Todo.Application/Migrations/20260413140347_UpdateSecurityEntities.Designer.cs b/src/Hua.Todo.Application/Migrations/20260413140347_UpdateSecurityEntities.Designer.cs new file mode 100644 index 0000000..e4f133e --- /dev/null +++ b/src/Hua.Todo.Application/Migrations/20260413140347_UpdateSecurityEntities.Designer.cs @@ -0,0 +1,219 @@ +// +using System; +using Hua.Todo.Application.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Hua.Todo.Application.Migrations +{ + [DbContext(typeof(TodoDbContext))] + [Migration("20260413140347_UpdateSecurityEntities")] + partial class UpdateSecurityEntities + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AllowPersist") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IsTrustedDeviceOnly") + .HasColumnType("INTEGER"); + + b.Property("SecondFactorExpiryMinutes") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("SecurityPolicies", (string)null); + }); + + modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("datetime('now')"); + + b.Property("IsCompleted") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("ParentTaskId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("datetime('now')"); + + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001")); + + b.HasKey("Id"); + + b.HasIndex("ParentTaskId"); + + b.HasIndex("UserId"); + + b.ToTable("Tasks", (string)null); + }); + + modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StepUpExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions", (string)null); + }); + + modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b => + { + b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => + { + b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask") + .WithMany("SubTasks") + .HasForeignKey("ParentTaskId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") + .WithMany("Tasks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ParentTask"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b => + { + b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => + { + b.Navigation("SubTasks"); + }); + + modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b => + { + b.Navigation("Tasks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Hua.Todo.Application/Migrations/20260413140347_UpdateSecurityEntities.cs b/src/Hua.Todo.Application/Migrations/20260413140347_UpdateSecurityEntities.cs new file mode 100644 index 0000000..286140e --- /dev/null +++ b/src/Hua.Todo.Application/Migrations/20260413140347_UpdateSecurityEntities.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hua.Todo.Application.Migrations +{ + /// + public partial class UpdateSecurityEntities : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CreatedAtUtc", + table: "Users", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "UpdatedAtUtc", + table: "Users", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "IsTrustedDeviceOnly", + table: "SecurityPolicies", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "SecondFactorExpiryMinutes", + table: "SecurityPolicies", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CreatedAtUtc", + table: "Users"); + + migrationBuilder.DropColumn( + name: "UpdatedAtUtc", + table: "Users"); + + migrationBuilder.DropColumn( + name: "IsTrustedDeviceOnly", + table: "SecurityPolicies"); + + migrationBuilder.DropColumn( + name: "SecondFactorExpiryMinutes", + table: "SecurityPolicies"); + } + } +} diff --git a/src/Hua.Todo.Application/Migrations/20260413140753_AddAuditLogs.Designer.cs b/src/Hua.Todo.Application/Migrations/20260413140753_AddAuditLogs.Designer.cs new file mode 100644 index 0000000..501263d --- /dev/null +++ b/src/Hua.Todo.Application/Migrations/20260413140753_AddAuditLogs.Designer.cs @@ -0,0 +1,269 @@ +// +using System; +using Hua.Todo.Application.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Hua.Todo.Application.Migrations +{ + [DbContext(typeof(TodoDbContext))] + [Migration("20260413140753_AddAuditLogs")] + partial class AddAuditLogs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("Hua.Todo.Core.Entities.AuditLogEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsSuccess") + .HasColumnType("INTEGER"); + + b.Property("TimestampUtc") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TimestampUtc"); + + b.HasIndex("UserId"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AllowPersist") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IsTrustedDeviceOnly") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("SecondFactorExpiryMinutes") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("SecurityPolicies", (string)null); + }); + + modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("datetime('now')"); + + b.Property("IsCompleted") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("ParentTaskId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("datetime('now')"); + + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001")); + + b.HasKey("Id"); + + b.HasIndex("ParentTaskId"); + + b.HasIndex("UserId"); + + b.ToTable("Tasks", (string)null); + }); + + modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StepUpExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions", (string)null); + }); + + modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b => + { + b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => + { + b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask") + .WithMany("SubTasks") + .HasForeignKey("ParentTaskId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") + .WithMany("Tasks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ParentTask"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b => + { + b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => + { + b.Navigation("SubTasks"); + }); + + modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b => + { + b.Navigation("Tasks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Hua.Todo.Application/Migrations/20260413140753_AddAuditLogs.cs b/src/Hua.Todo.Application/Migrations/20260413140753_AddAuditLogs.cs new file mode 100644 index 0000000..5aa5f95 --- /dev/null +++ b/src/Hua.Todo.Application/Migrations/20260413140753_AddAuditLogs.cs @@ -0,0 +1,87 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hua.Todo.Application.Migrations +{ + /// + public partial class AddAuditLogs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "SecondFactorExpiryMinutes", + table: "SecurityPolicies", + type: "INTEGER", + nullable: false, + defaultValue: 30, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "IsTrustedDeviceOnly", + table: "SecurityPolicies", + type: "INTEGER", + nullable: false, + defaultValue: false, + oldClrType: typeof(bool), + oldType: "INTEGER"); + + migrationBuilder.CreateTable( + name: "AuditLogs", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + TimestampUtc = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: true), + UserName = table.Column(type: "TEXT", nullable: true), + EventType = table.Column(type: "TEXT", maxLength: 64, nullable: false), + Description = table.Column(type: "TEXT", maxLength: 500, nullable: false), + ClientIp = table.Column(type: "TEXT", maxLength: 64, nullable: true), + UserAgent = table.Column(type: "TEXT", maxLength: 500, nullable: true), + IsSuccess = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditLogs", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_TimestampUtc", + table: "AuditLogs", + column: "TimestampUtc"); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_UserId", + table: "AuditLogs", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AuditLogs"); + + migrationBuilder.AlterColumn( + name: "SecondFactorExpiryMinutes", + table: "SecurityPolicies", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER", + oldDefaultValue: 30); + + migrationBuilder.AlterColumn( + name: "IsTrustedDeviceOnly", + table: "SecurityPolicies", + type: "INTEGER", + nullable: false, + oldClrType: typeof(bool), + oldType: "INTEGER", + oldDefaultValue: false); + } + } +} diff --git a/src/Hua.Todo.Application/Migrations/TodoDbContextModelSnapshot.cs b/src/Hua.Todo.Application/Migrations/TodoDbContextModelSnapshot.cs index 8a59e23..9d3ec9c 100644 --- a/src/Hua.Todo.Application/Migrations/TodoDbContextModelSnapshot.cs +++ b/src/Hua.Todo.Application/Migrations/TodoDbContextModelSnapshot.cs @@ -17,6 +17,52 @@ namespace Hua.Todo.Application.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + modelBuilder.Entity("Hua.Todo.Core.Entities.AuditLogEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsSuccess") + .HasColumnType("INTEGER"); + + b.Property("TimestampUtc") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TimestampUtc"); + + b.HasIndex("UserId"); + + b.ToTable("AuditLogs", (string)null); + }); + modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b => { b.Property("Id") @@ -33,6 +79,16 @@ namespace Hua.Todo.Application.Migrations .HasColumnType("INTEGER") .HasDefaultValue(true); + b.Property("IsTrustedDeviceOnly") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("SecondFactorExpiryMinutes") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30); + b.Property("UserId") .HasColumnType("TEXT"); @@ -50,7 +106,8 @@ namespace Hua.Todo.Application.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("CreatedAt") + b.Property("CreatedAt") + .IsRequired() .ValueGeneratedOnAdd() .HasColumnType("TEXT") .HasDefaultValueSql("datetime('now')"); @@ -73,7 +130,8 @@ namespace Hua.Todo.Application.Migrations .HasMaxLength(200) .HasColumnType("TEXT"); - b.Property("UpdatedAt") + b.Property("UpdatedAt") + .IsRequired() .ValueGeneratedOnAdd() .HasColumnType("TEXT") .HasDefaultValueSql("datetime('now')"); @@ -98,6 +156,9 @@ namespace Hua.Todo.Application.Migrations .ValueGeneratedOnAdd() .HasColumnType("TEXT"); + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + b.Property("PasswordHash") .IsRequired() .HasColumnType("TEXT"); @@ -107,6 +168,9 @@ namespace Hua.Todo.Application.Migrations .HasMaxLength(32) .HasColumnType("TEXT"); + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + b.Property("UserName") .IsRequired() .HasMaxLength(64) @@ -126,13 +190,15 @@ namespace Hua.Todo.Application.Migrations .ValueGeneratedOnAdd() .HasColumnType("TEXT"); - b.Property("CreatedAtUtc") + b.Property("CreatedAtUtc") + .IsRequired() .HasColumnType("TEXT"); - b.Property("ExpiresAtUtc") + b.Property("ExpiresAtUtc") + .IsRequired() .HasColumnType("TEXT"); - b.Property("StepUpExpiresAtUtc") + b.Property("StepUpExpiresAtUtc") .HasColumnType("TEXT"); b.Property("UserId") diff --git a/src/Hua.Todo.Core/Entities/AuditLogEntity.cs b/src/Hua.Todo.Core/Entities/AuditLogEntity.cs new file mode 100644 index 0000000..bfb7bed --- /dev/null +++ b/src/Hua.Todo.Core/Entities/AuditLogEntity.cs @@ -0,0 +1,52 @@ +namespace Hua.Todo.Core.Entities; + +/// +/// 安全审计日志实体。 +/// +public class AuditLogEntity +{ + /// + /// 日志唯一标识符。 + /// + public Guid Id { get; set; } + + /// + /// 事件发生的 UTC 时间。 + /// + public DateTime TimestampUtc { get; set; } = DateTime.UtcNow; + + /// + /// 相关用户 ID(可选,若为登录失败等场景可能为空)。 + /// + public Guid? UserId { get; set; } + + /// + /// 相关用户名。 + /// + public string? UserName { get; set; } + + /// + /// 事件类型(如 LoginSuccess, LoginFailed, SyncStart, PolicyChanged 等)。 + /// + public string EventType { get; set; } = string.Empty; + + /// + /// 事件描述或详细信息。 + /// + public string Description { get; set; } = string.Empty; + + /// + /// 操作来源 IP。 + /// + public string? ClientIp { get; set; } + + /// + /// 终端信息(User-Agent 等)。 + /// + public string? UserAgent { get; set; } + + /// + /// 是否成功。 + /// + public bool IsSuccess { get; set; } = true; +} diff --git a/src/Hua.Todo.Core/Entities/SecurityPolicyEntity.cs b/src/Hua.Todo.Core/Entities/SecurityPolicyEntity.cs index f341ad7..d9e778a 100644 --- a/src/Hua.Todo.Core/Entities/SecurityPolicyEntity.cs +++ b/src/Hua.Todo.Core/Entities/SecurityPolicyEntity.cs @@ -29,4 +29,14 @@ public class SecurityPolicyEntity /// 是否允许执行同步写入(为 false 时应视为“禁止同步”)。 /// public bool AllowSync { get; set; } = true; + + /// + /// 二次认证(Step-up)状态保持时长(分钟)。 + /// + public int SecondFactorExpiryMinutes { get; set; } = 30; + + /// + /// 是否仅限受信任终端执行高风险操作。 + /// + public bool IsTrustedDeviceOnly { get; set; } = false; } diff --git a/src/Hua.Todo.Core/Entities/UserEntity.cs b/src/Hua.Todo.Core/Entities/UserEntity.cs index ff4294e..841e456 100644 --- a/src/Hua.Todo.Core/Entities/UserEntity.cs +++ b/src/Hua.Todo.Core/Entities/UserEntity.cs @@ -25,6 +25,16 @@ public class UserEntity /// public string Role { get; set; } = "user"; + /// + /// 创建时间(UTC)。 + /// + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; + + /// + /// 最后更新时间(UTC)。 + /// + public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; + /// /// 用户任务集合。 /// diff --git a/src/Hua.Todo.Host/Program.cs b/src/Hua.Todo.Host/Program.cs index efe6e47..a5305a5 100644 --- a/src/Hua.Todo.Host/Program.cs +++ b/src/Hua.Todo.Host/Program.cs @@ -5,7 +5,7 @@ using Hua.Todo.Application.CloudSync; using Hua.Todo.Application.DynamicApi; using Hua.Todo.Application.DynamicApi.Swagger; using Hua.Todo.Application.Interfaces; -using Hua.Todo.Application.Models; +using Hua.Todo.Application.CloudSync.Services; var builder = WebApplication.CreateBuilder(args); @@ -30,11 +30,24 @@ builder.Services.AddCors(options => var app = builder.Build(); -// Apply database migrations +// Apply database migrations and seed default admin using (var scope = app.Services.CreateScope()) { - var dbContext = scope.ServiceProvider.GetRequiredService(); + var services = scope.ServiceProvider; + var dbContext = services.GetRequiredService(); dbContext.Database.Migrate(); + + // Seed default admin from configuration + var config = services.GetRequiredService(); + var adminConfig = config.GetSection("DefaultAdmin"); + var adminUserName = adminConfig["UserName"]; + var adminPassword = adminConfig["Password"]; + + if (!string.IsNullOrWhiteSpace(adminUserName) && !string.IsNullOrWhiteSpace(adminPassword)) + { + var authService = services.GetRequiredService(); + await authService.BootstrapAdminAsync(adminUserName, adminPassword, "system", "seeding", CancellationToken.None); + } } if (app.Environment.IsDevelopment()) @@ -44,9 +57,14 @@ if (app.Environment.IsDevelopment()) } app.UseHttpsRedirection(); +app.UseStaticFiles(); app.UseCors("AllowAll"); app.UseAuthentication(); app.UseAuthorization(); + +// 简单的管理后台入口跳转 +app.MapGet("/admin", () => Results.Redirect("/admin/index.html")); + app.MapCloudSyncEndpoints(); app.UseDynamicApi(); diff --git a/src/Hua.Todo.Host/appsettings.json b/src/Hua.Todo.Host/appsettings.json index 10f68b8..26b0679 100644 --- a/src/Hua.Todo.Host/appsettings.json +++ b/src/Hua.Todo.Host/appsettings.json @@ -5,5 +5,9 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "DefaultAdmin": { + "UserName": "admin", + "Password": "ChangeMe@123" + } } diff --git a/src/Hua.Todo.Host/wwwroot/admin/index.html b/src/Hua.Todo.Host/wwwroot/admin/index.html new file mode 100644 index 0000000..b778db9 --- /dev/null +++ b/src/Hua.Todo.Host/wwwroot/admin/index.html @@ -0,0 +1,390 @@ + + + + + + Hua.Todo 管理后台 + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/src/Hua.Todo.Web/src/api/cloudClient.ts b/src/Hua.Todo.Web/src/api/cloudClient.ts index 67c3431..dcdb464 100644 --- a/src/Hua.Todo.Web/src/api/cloudClient.ts +++ b/src/Hua.Todo.Web/src/api/cloudClient.ts @@ -1,5 +1,6 @@ import axios from 'axios'; import CloudSyncStorage from '../services/cloudSyncStorage'; +import { cloudSyncState } from '../services/cloudSyncState'; const showNotification = (message: string, type: 'error' | 'success' = 'error') => { if ((window as any).showToast) { @@ -52,6 +53,15 @@ cloudClient.interceptors.response.use( let errorMessage = '网络错误,请稍后再试'; const status = error?.response?.status as number | undefined; + const errorCode = error?.response?.data?.code as string | undefined; + + if (status === 403 && errorCode === 'SECOND_FACTOR_REQUIRED') { + // 捕获“需要二次认证”错误,触发二次认证弹窗,并中断当前请求(不弹普通错误提示) + cloudSyncState.isSteppingUp = true; + // 注意:这里我们暂不自动重试,让用户在弹窗输入密码后再手动触发(或由 UI 层处理重试) + return Promise.reject(error); + } + if (status === 401 || status === 403) { CloudSyncStorage.clearSession(); requestCloudSyncReLogin(); diff --git a/src/Hua.Todo.Web/src/api/cloudSync.ts b/src/Hua.Todo.Web/src/api/cloudSync.ts index 668bef4..01afda7 100644 --- a/src/Hua.Todo.Web/src/api/cloudSync.ts +++ b/src/Hua.Todo.Web/src/api/cloudSync.ts @@ -25,6 +25,20 @@ export interface CloudTaskItem { parentTaskId?: number | null; } +export interface SecurityPolicyResponse { + allowPersist: boolean; + allowSync: boolean; + requireSecondFactorFor: string[]; +} + +export interface StepUpRequest { + password: string; +} + +export interface StepUpResponse { + stepUpExpiresAtUtc: string; +} + /** * 把 CloudSync 的扁平任务列表(ParentTaskId)组装成前端需要的树结构(subTasks)。 */ @@ -93,6 +107,22 @@ export const cloudSyncApi = { const items = Array.isArray(response.data) ? response.data : []; return buildTaskTreeFromCloudItems(items); }, + + /** + * 获取当前用户的安全策略。 + */ + async getSecurityPolicy(): Promise { + const response = await cloudClient.get('/security/policy'); + return response.data; + }, + + /** + * 执行二次认证(Step-up)。 + */ + async stepUp(password: string): Promise { + const response = await cloudClient.post('/auth/step-up', { password }); + return response.data; + }, }; export default cloudSyncApi; diff --git a/src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue b/src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue index 59a54e6..3e9f9df 100644 --- a/src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue +++ b/src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue @@ -47,6 +47,20 @@
已登录:{{ sessionSummary }}
+
+
+ 落盘策略: + + {{ policy.allowPersist ? '允许(本地持久化)' : '禁止(仅限内存)' }} + +
+
+ 同步权限: + + {{ policy.allowSync ? '正常' : '已禁用' }} + +
+
+ + +
+
+
+

🔐 二次认证

+
+
+
当前操作属于高风险操作,请验证您的登录密码。
+
+ +
+
{{ stepUpError }}
+
+ +
+
@@ -421,6 +524,39 @@ onMounted(() => { font-size: 12px; } +.policy-info { + margin-top: 10px; + padding: 10px; + background: #f9fafb; + border-radius: 8px; + border: 1px solid #f3f4f6; + display: flex; + flex-direction: column; + gap: 6px; +} + +.policy-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; +} + +.policy-item .label { + color: #6b7280; + width: 70px; +} + +.policy-item .success { + color: #059669; + font-weight: 550; +} + +.policy-item .warn { + color: #d97706; + font-weight: 550; +} + .form-row { display: flex; gap: 10px; @@ -497,6 +633,21 @@ onMounted(() => { background: #f9fafb; } +.step-up-overlay { + z-index: 1100; + background: rgba(0, 0, 0, 0.6); +} + +.step-up-dialog { + max-width: 400px; +} + +.error-text { + color: #ef4444; + font-size: 13px; + margin-top: 8px; +} + .btn { padding: 10px 14px; border: none; diff --git a/src/Hua.Todo.Web/src/services/cloudSyncState.ts b/src/Hua.Todo.Web/src/services/cloudSyncState.ts new file mode 100644 index 0000000..207e0a8 --- /dev/null +++ b/src/Hua.Todo.Web/src/services/cloudSyncState.ts @@ -0,0 +1,34 @@ +import { reactive } from 'vue'; +import type { CloudSyncSession } from '../services/cloudSyncStorage'; + +/** + * 云同步运行时状态。 + * 此状态仅保留在内存中,刷新页面即丢失。 + */ +export const cloudSyncState = reactive({ + /** + * 当前会话信息(从 CloudSyncStorage 加载或登录后设置)。 + */ + session: null as CloudSyncSession | null, + + /** + * 服务端下发的安全策略。 + */ + policy: { + allowPersist: true, + allowSync: true, + requireSecondFactorFor: [] as string[], + }, + + /** + * 是否正在进行二次认证流程。 + */ + isSteppingUp: false, + + /** + * 二次认证成功后的回调(用于重试之前失败的操作)。 + */ + onStepUpSuccess: null as (() => void) | null, +}); + +export default cloudSyncState; diff --git a/src/Hua.Todo.Web/src/services/localStorageService.ts b/src/Hua.Todo.Web/src/services/localStorageService.ts index 28142d7..9c2e7c3 100644 --- a/src/Hua.Todo.Web/src/services/localStorageService.ts +++ b/src/Hua.Todo.Web/src/services/localStorageService.ts @@ -4,6 +4,20 @@ import { normalizeTasks } from './taskNormalizer'; const STORAGE_KEY = 'Hua.Todo_tasks'; const SYNC_STATUS_KEY = 'Hua.Todo_sync_status'; +/** + * 内存存储备份(当禁止落盘时使用)。 + */ +const memoryStore = { + tasks: [] as Task[], + syncStatus: null as SyncStatus | null, +}; + +/** + * 当前是否允许落盘。 + * 由外部(如 CloudSyncSettingsDialog)根据服务端策略动态设置。 + */ +let allowPersist = true; + export interface SyncStatus { lastSyncTime: number; isOnline: boolean; @@ -11,15 +25,38 @@ export interface SyncStatus { } export class LocalStorageService { + /** + * 设置落盘策略。若从允许切换到禁止,则清空已落盘数据。 + */ + static setAllowPersist(allowed: boolean): void { + const previous = allowPersist; + allowPersist = allowed; + if (previous && !allowed) { + this.clearTasks(); + localStorage.removeItem(SYNC_STATUS_KEY); + console.log('CloudSync: Security policy changed to "No Persist", local data cleared.'); + } + } + static saveTasks(tasks: Task[]): void { + const normalized = normalizeTasks(tasks); + if (!allowPersist) { + memoryStore.tasks = normalized; + return; + } + try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(normalizeTasks(tasks))); + localStorage.setItem(STORAGE_KEY, JSON.stringify(normalized)); } catch (error) { console.error('Failed to save tasks to localStorage:', error); } } static loadTasks(): Task[] { + if (!allowPersist) { + return memoryStore.tasks; + } + try { const data = localStorage.getItem(STORAGE_KEY); return data ? normalizeTasks(JSON.parse(data)) : []; @@ -30,6 +67,7 @@ export class LocalStorageService { } static clearTasks(): void { + memoryStore.tasks = []; try { localStorage.removeItem(STORAGE_KEY); } catch (error) { @@ -38,6 +76,11 @@ export class LocalStorageService { } static saveSyncStatus(status: SyncStatus): void { + if (!allowPersist) { + memoryStore.syncStatus = status; + return; + } + try { localStorage.setItem(SYNC_STATUS_KEY, JSON.stringify(status)); } catch (error) { @@ -46,6 +89,10 @@ export class LocalStorageService { } static loadSyncStatus(): SyncStatus { + if (!allowPersist && memoryStore.syncStatus) { + return memoryStore.syncStatus; + } + try { const data = localStorage.getItem(SYNC_STATUS_KEY); return data ? JSON.parse(data) : {