feat:1.2.0初始版本未自测

This commit is contained in:
ShaoHua
2026-04-13 23:10:07 +08:00
parent 1f87565d5a
commit 3dbd97103c
32 changed files with 2181 additions and 48 deletions
@@ -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
@@ -13,7 +13,8 @@
## 依赖
- 依赖 `04-*` 提供策略下发与二次认证能力(至少一种实现
- 依赖 `04-*` 提供策略下发与二次认证能力(接口契约
- 依赖 `06.1-*` 提供服务端底层安全实现(账户/密码/Token/策略存储)
- 依赖 `05-*` 的基础登录/同步 UI 入口
## 关键设计点(v1.2.0 必须明确)
@@ -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)机制。
@@ -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] 高风险操作触发二次认证(服务端支持时)
+8
View File
@@ -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" "$@"
+39
View File
@@ -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)。
+10
View File
@@ -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
+1 -1
View File
@@ -7,7 +7,7 @@
约束:
- Avalonia Linux 入口项目需先落地(参见 docs/project/v1.2.0-tasks/01-*
- 仅打包为 tar.gzFlatpak/AppImage 需要在 Linux 环境执行相关工具链
- 仅打包为 tar.gzFlatpak/AppImage 需要在 Linux 环境执行相关工具链(参见 pack/linux/ 说明)
#>
param(
@@ -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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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
}
@@ -25,6 +25,7 @@ public static class CloudSyncServiceCollectionExtensions
services.AddScoped<IPasswordHasher<UserEntity>, PasswordHasher<UserEntity>>();
services.AddScoped<CloudAuthService>();
services.AddScoped<CloudAdminService>();
services.AddScoped<CloudTaskSyncService>();
services.AddScoped<SecurityPolicyService>();
@@ -0,0 +1,60 @@
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 用户信息 DTO(管理端使用)。
/// </summary>
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; }
}
/// <summary>
/// 创建用户请求。
/// </summary>
public class CreateUserRequest
{
public string UserName { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string Role { get; set; } = "user";
}
/// <summary>
/// 重置密码请求。
/// </summary>
public class ResetPasswordRequest
{
public string NewPassword { get; set; } = string.Empty;
}
/// <summary>
/// 审计日志 DTO。
/// </summary>
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; }
}
/// <summary>
/// 会话信息 DTO。
/// </summary>
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; }
}
@@ -19,6 +19,16 @@ public class SecurityPolicyDto
/// 需要二次认证的操作列表(操作码)。
/// </summary>
public List<string> RequireSecondFactorFor { get; set; } = new();
/// <summary>
/// 二次认证有效期(分钟)。
/// </summary>
public int SecondFactorExpiryMinutes { get; set; }
/// <summary>
/// 是否仅限受信任设备。
/// </summary>
public bool IsTrustedDeviceOnly { get; set; }
}
/// <summary>
@@ -35,4 +45,14 @@ public class UpdateSecurityPolicyRequest
/// 是否允许同步写入。
/// </summary>
public bool AllowSync { get; set; }
/// <summary>
/// 二次认证有效期(分钟)。
/// </summary>
public int SecondFactorExpiryMinutes { get; set; }
/// <summary>
/// 是否仅限受信任设备。
/// </summary>
public bool IsTrustedDeviceOnly { get; set; }
}
@@ -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;
/// <summary>
/// 管理端服务(账户、会话、审计、全局策略管理)。
/// </summary>
public class CloudAdminService
{
private readonly TodoDbContext _dbContext;
private readonly IPasswordHasher<UserEntity> _passwordHasher;
public CloudAdminService(TodoDbContext dbContext, IPasswordHasher<UserEntity> passwordHasher)
{
_dbContext = dbContext;
_passwordHasher = passwordHasher;
}
#region User Management
public async Task<List<UserDto>> 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<UserDto?> 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<bool> 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<bool> 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<List<SessionDto>> 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<bool> 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<List<AuditLogDto>> 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
}
@@ -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();
}
/// <summary>
/// 初始化系统管理员账号(仅在系统尚无云用户时可用)。
/// </summary>
/// <param name="userName">用户名。</param>
/// <param name="password">密码。</param>
/// <param name="clientIp">客户端 IP。</param>
/// <param name="userAgent">User-Agent。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>是否初始化成功。</returns>
public async Task<bool> BootstrapAdminAsync(string userName, string password, CancellationToken cancellationToken)
public async Task<bool> 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
/// <param name="userName">用户名。</param>
/// <param name="password">密码。</param>
/// <param name="sessionTtl">会话有效期。</param>
/// <param name="clientIp">客户端 IP。</param>
/// <param name="userAgent">User-Agent。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>登录响应;失败则返回 null。</returns>
public async Task<LoginResponse?> LoginAsync(string userName, string password, TimeSpan sessionTtl, CancellationToken cancellationToken)
public async Task<LoginResponse?> 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
/// </summary>
/// <param name="sessionId">会话 ID。</param>
/// <param name="password">口令。</param>
/// <param name="stepUpTtl">二次认证有效期。</param>
/// <param name="clientIp">客户端 IP。</param>
/// <param name="userAgent">User-Agent。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>有效期截止时间;失败返回 null。</returns>
public async Task<DateTime?> StepUpAsync(Guid sessionId, string password, TimeSpan stepUpTtl, CancellationToken cancellationToken)
public async Task<DateTime?> 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;
}
}
@@ -37,7 +37,9 @@ public class SecurityPolicyService
{
AllowPersist = policy?.AllowPersist ?? true,
AllowSync = policy?.AllowSync ?? true,
RequireSecondFactorFor = new List<string> { "sync:write", "policy:write" }
RequireSecondFactorFor = new List<string> { "sync:write", "policy:write" },
SecondFactorExpiryMinutes = policy?.SecondFactorExpiryMinutes ?? 30,
IsTrustedDeviceOnly = policy?.IsTrustedDeviceOnly ?? false
};
}
@@ -46,20 +48,33 @@ public class SecurityPolicyService
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <param name="allowPersist">是否允许落盘。</param>
/// <param name="allowSync">是否允许同步。</param>
/// <param name="secondFactorExpiryMinutes">二次认证有效期。</param>
/// <param name="isTrustedDeviceOnly">是否仅限受信任设备。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>更新后的策略 DTO。</returns>
public async Task<SecurityPolicyDto> UpdatePolicyAsync(Guid userId, bool allowPersist, bool allowSync, CancellationToken cancellationToken)
public async Task<SecurityPolicyDto> 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);
@@ -37,6 +37,11 @@ public class TodoDbContext : DbContext
/// </summary>
public DbSet<SecurityPolicyEntity> SecurityPolicies { get; set; }
/// <summary>
/// 安全审计日志集合。
/// </summary>
public DbSet<AuditLogEntity> AuditLogs { get; set; }
/// <summary>
/// 配置实体模型映射。
/// </summary>
@@ -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<AuditLogEntity>(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);
});
}
}
@@ -0,0 +1,219 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowPersist")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("AllowSync")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("IsTrustedDeviceOnly")
.HasColumnType("INTEGER");
b.Property<int>("SecondFactorExpiryMinutes")
.HasColumnType("INTEGER");
b.Property<Guid>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CreatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int?>("ParentTaskId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("UpdatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CreatedAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ExpiresAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("StepUpExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("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
}
}
}
@@ -0,0 +1,63 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class UpdateSecurityEntities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "CreatedAtUtc",
table: "Users",
type: "TEXT",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<DateTime>(
name: "UpdatedAtUtc",
table: "Users",
type: "TEXT",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<bool>(
name: "IsTrustedDeviceOnly",
table: "SecurityPolicies",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "SecondFactorExpiryMinutes",
table: "SecurityPolicies",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
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");
}
}
}
@@ -0,0 +1,269 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ClientIp")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("IsSuccess")
.HasColumnType("INTEGER");
b.Property<string>("TimestampUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("UserAgent")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<Guid?>("UserId")
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowPersist")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("AllowSync")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("IsTrustedDeviceOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int>("SecondFactorExpiryMinutes")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30);
b.Property<Guid>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CreatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int?>("ParentTaskId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("UpdatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CreatedAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ExpiresAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("StepUpExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("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
}
}
}
@@ -0,0 +1,87 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class AddAuditLogs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "SecondFactorExpiryMinutes",
table: "SecurityPolicies",
type: "INTEGER",
nullable: false,
defaultValue: 30,
oldClrType: typeof(int),
oldType: "INTEGER");
migrationBuilder.AlterColumn<bool>(
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<Guid>(type: "TEXT", nullable: false),
TimestampUtc = table.Column<string>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: true),
UserName = table.Column<string>(type: "TEXT", nullable: true),
EventType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Description = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
ClientIp = table.Column<string>(type: "TEXT", maxLength: 64, nullable: true),
UserAgent = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
IsSuccess = table.Column<bool>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AuditLogs");
migrationBuilder.AlterColumn<int>(
name: "SecondFactorExpiryMinutes",
table: "SecurityPolicies",
type: "INTEGER",
nullable: false,
oldClrType: typeof(int),
oldType: "INTEGER",
oldDefaultValue: 30);
migrationBuilder.AlterColumn<bool>(
name: "IsTrustedDeviceOnly",
table: "SecurityPolicies",
type: "INTEGER",
nullable: false,
oldClrType: typeof(bool),
oldType: "INTEGER",
oldDefaultValue: false);
}
}
}
@@ -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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ClientIp")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("IsSuccess")
.HasColumnType("INTEGER");
b.Property<string>("TimestampUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("UserAgent")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<Guid?>("UserId")
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
@@ -33,6 +79,16 @@ namespace Hua.Todo.Application.Migrations
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("IsTrustedDeviceOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int>("SecondFactorExpiryMinutes")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30);
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
@@ -50,7 +106,8 @@ namespace Hua.Todo.Application.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
b.Property<string>("CreatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
@@ -73,7 +130,8 @@ namespace Hua.Todo.Application.Migrations
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
b.Property<string>("UpdatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
@@ -98,6 +156,9 @@ namespace Hua.Todo.Application.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
@@ -107,6 +168,9 @@ namespace Hua.Todo.Application.Migrations
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
@@ -126,13 +190,15 @@ namespace Hua.Todo.Application.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
b.Property<string>("CreatedAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("ExpiresAtUtc")
b.Property<string>("ExpiresAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("StepUpExpiresAtUtc")
b.Property<string>("StepUpExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
@@ -0,0 +1,52 @@
namespace Hua.Todo.Core.Entities;
/// <summary>
/// 安全审计日志实体。
/// </summary>
public class AuditLogEntity
{
/// <summary>
/// 日志唯一标识符。
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// 事件发生的 UTC 时间。
/// </summary>
public DateTime TimestampUtc { get; set; } = DateTime.UtcNow;
/// <summary>
/// 相关用户 ID(可选,若为登录失败等场景可能为空)。
/// </summary>
public Guid? UserId { get; set; }
/// <summary>
/// 相关用户名。
/// </summary>
public string? UserName { get; set; }
/// <summary>
/// 事件类型(如 LoginSuccess, LoginFailed, SyncStart, PolicyChanged 等)。
/// </summary>
public string EventType { get; set; } = string.Empty;
/// <summary>
/// 事件描述或详细信息。
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// 操作来源 IP。
/// </summary>
public string? ClientIp { get; set; }
/// <summary>
/// 终端信息(User-Agent 等)。
/// </summary>
public string? UserAgent { get; set; }
/// <summary>
/// 是否成功。
/// </summary>
public bool IsSuccess { get; set; } = true;
}
@@ -29,4 +29,14 @@ public class SecurityPolicyEntity
/// 是否允许执行同步写入(为 false 时应视为“禁止同步”)。
/// </summary>
public bool AllowSync { get; set; } = true;
/// <summary>
/// 二次认证(Step-up)状态保持时长(分钟)。
/// </summary>
public int SecondFactorExpiryMinutes { get; set; } = 30;
/// <summary>
/// 是否仅限受信任终端执行高风险操作。
/// </summary>
public bool IsTrustedDeviceOnly { get; set; } = false;
}
+10
View File
@@ -25,6 +25,16 @@ public class UserEntity
/// </summary>
public string Role { get; set; } = "user";
/// <summary>
/// 创建时间(UTC)。
/// </summary>
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
/// <summary>
/// 最后更新时间(UTC)。
/// </summary>
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
/// <summary>
/// 用户任务集合。
/// </summary>
+21 -3
View File
@@ -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<Hua.Todo.Application.Data.TodoDbContext>();
var services = scope.ServiceProvider;
var dbContext = services.GetRequiredService<Hua.Todo.Application.Data.TodoDbContext>();
dbContext.Database.Migrate();
// Seed default admin from configuration
var config = services.GetRequiredService<IConfiguration>();
var adminConfig = config.GetSection("DefaultAdmin");
var adminUserName = adminConfig["UserName"];
var adminPassword = adminConfig["Password"];
if (!string.IsNullOrWhiteSpace(adminUserName) && !string.IsNullOrWhiteSpace(adminPassword))
{
var authService = services.GetRequiredService<CloudAuthService>();
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();
+5 -1
View File
@@ -5,5 +5,9 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"DefaultAdmin": {
"UserName": "admin",
"Password": "ChangeMe@123"
}
}
+390
View File
@@ -0,0 +1,390 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hua.Todo 管理后台</title>
<!-- Element Plus & Vue 3 via CDN -->
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css">
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/element-plus"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<style>
body { margin: 0; font-family: -apple-system, sans-serif; background: #f5f7fa; }
#app { height: 100vh; display: flex; flex-direction: column; }
.header { background: #409EFF; color: white; padding: 0 20px; height: 60px; line-height: 60px; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.main-container { flex: 1; overflow: hidden; display: flex; }
.sidebar { width: 200px; background: #fff; border-right: 1px solid #e6e6e6; }
.content { flex: 1; padding: 20px; overflow-y: auto; }
.login-container { height: 100vh; display: flex; justify-content: center; align-items: center; background: #f5f7fa; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
.tag-admin { background-color: #f56c6c !important; border-color: #f56c6c !important; color: white !important; }
.audit-failed { color: #f56c6c; }
.audit-success { color: #67c23a; }
</style>
</head>
<body>
<div id="app">
<!-- 登录界面 -->
<div v-if="!isLoggedIn" class="login-container">
<el-card style="width: 400px;">
<template #header>
<div style="text-align: center; font-weight: bold;">管理员登录</div>
</template>
<el-form label-position="top">
<el-form-item label="用户名">
<el-input v-model="loginForm.userName" prefix-icon="User"></el-input>
</el-form-item>
<el-form-item label="密码">
<el-input v-model="loginForm.password" type="password" prefix-icon="Lock" show-password @keyup.enter="handleLogin"></el-input>
</el-form-item>
<el-button type="primary" style="width: 100%" @click="handleLogin" :loading="loggingIn">登录</el-button>
</el-form>
</el-card>
</div>
<!-- 主界面 -->
<template v-else>
<div class="header">
<div style="font-size: 1.2rem; font-weight: bold;">Hua.Todo 管理后台 <small style="font-size: 0.8rem; opacity: 0.8;">v1.2.0</small></div>
<div>
<span style="margin-right: 15px;">欢迎, {{ currentUser.userName }}</span>
<el-button type="info" size="small" @click="refreshAll">刷新</el-button>
<el-button type="danger" size="small" @click="handleLogout">退出</el-button>
</div>
</div>
<div class="main-container">
<div class="sidebar">
<el-menu :default-active="activeTab" @select="handleTabSelect" style="border-right: none;">
<el-menu-item index="users">用户管理</el-menu-item>
<el-menu-item index="sessions">会话管理</el-menu-item>
<el-menu-item index="logs">审计日志</el-menu-item>
</el-menu>
</div>
<div class="content">
<!-- 用户管理 -->
<div v-if="activeTab === 'users'">
<el-card>
<template #header>
<div class="card-header">
<span>用户列表</span>
<el-button type="primary" @click="showCreateUserDialog = true">新增用户</el-button>
</div>
</template>
<el-table :data="users" v-loading="loadingUsers" style="width: 100%">
<el-table-column prop="userName" label="用户名" width="180"></el-table-column>
<el-table-column prop="role" label="角色" width="120">
<template #default="scope">
<el-tag :class="scope.row.role === 'admin' ? 'tag-admin' : ''">{{ scope.row.role }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAtUtc" label="创建时间" width="200">
<template #default="scope">{{ formatDate(scope.row.createdAtUtc) }}</template>
</el-table-column>
<el-table-column label="操作">
<template #default="scope">
<el-button size="small" @click="handleResetPassword(scope.row)">重置密码</el-button>
<el-button size="small" type="danger" @click="handleDeleteUser(scope.row)" :disabled="scope.row.role === 'admin'">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
<!-- 会话管理 -->
<div v-if="activeTab === 'sessions'">
<el-card>
<template #header>
<div class="card-header">
<span>当前在线会话</span>
<el-button size="small" @click="fetchSessions">刷新</el-button>
</div>
</template>
<el-table :data="sessions" v-loading="loadingSessions" style="width: 100%">
<el-table-column prop="userName" label="用户名" width="150"></el-table-column>
<el-table-column prop="isSteppedUp" label="已二次认证" width="120">
<template #default="scope">
<el-tag :type="scope.row.isSteppedUp ? 'success' : 'info'">{{ scope.row.isSteppedUp ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="expiresAtUtc" label="过期时间" width="200">
<template #default="scope">{{ formatDate(scope.row.expiresAtUtc) }}</template>
</el-table-column>
<el-table-column label="操作">
<template #default="scope">
<el-button size="small" type="warning" @click="handleRevokeSession(scope.row)">强制下线</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
<!-- 审计日志 -->
<div v-if="activeTab === 'logs'">
<el-card>
<template #header>
<div class="card-header">
<span>最近 100 条审计日志</span>
<el-button size="small" @click="fetchLogs">刷新</el-button>
</div>
</template>
<el-table :data="logs" v-loading="loadingLogs" stripe style="width: 100%; font-size: 0.85rem;">
<el-table-column prop="timestampUtc" label="时间" width="160">
<template #default="scope">{{ formatDate(scope.row.timestampUtc) }}</template>
</el-table-column>
<el-table-column prop="eventType" label="事件" width="140"></el-table-column>
<el-table-column prop="userName" label="用户" width="120"></el-table-column>
<el-table-column prop="description" label="描述"></el-table-column>
<el-table-column prop="isSuccess" label="结果" width="80">
<template #default="scope">
<span :class="scope.row.isSuccess ? 'audit-success' : 'audit-failed'">{{ scope.row.isSuccess ? '成功' : '失败' }}</span>
</template>
</el-table-column>
<el-table-column prop="clientIp" label="IP" width="120"></el-table-column>
</el-table>
</el-card>
</div>
</div>
</div>
</template>
<!-- 新增用户对话框 -->
<el-dialog v-model="showCreateUserDialog" title="新增用户" width="400px">
<el-form :model="newUser" label-width="80px">
<el-form-item label="用户名">
<el-input v-model="newUser.userName"></el-input>
</el-form-item>
<el-form-item label="密码">
<el-input v-model="newUser.password" type="password" show-password></el-input>
</el-form-item>
<el-form-item label="角色">
<el-select v-model="newUser.role" style="width: 100%">
<el-option label="普通用户" value="user"></el-option>
<el-option label="管理员" value="admin"></el-option>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateUserDialog = false">取消</el-button>
<el-button type="primary" @click="handleCreateUser" :loading="creatingUser">确定</el-button>
</template>
</el-dialog>
</div>
<script>
const { createApp, ref, reactive, onMounted } = Vue;
createApp({
setup() {
const activeTab = ref('users');
const isLoggedIn = ref(false);
const loggingIn = ref(false);
const loginForm = reactive({ userName: '', password: '' });
const currentUser = ref({ userName: '', role: '' });
const users = ref([]);
const sessions = ref([]);
const logs = ref([]);
const loadingUsers = ref(false);
const loadingSessions = ref(false);
const loadingLogs = ref(false);
const showCreateUserDialog = ref(false);
const creatingUser = ref(false);
const newUser = reactive({ userName: '', password: '', role: 'user' });
// Axios 拦截器:注入 Token
axios.interceptors.request.use(config => {
const token = localStorage.getItem('admin_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Axios 拦截器:处理鉴权失败
axios.interceptors.response.use(
response => response,
error => {
if (error.response && (error.response.status === 401 || error.response.status === 403)) {
handleLogout();
ElementPlus.ElMessage.error('会话已失效或权限不足,请重新登录');
}
return Promise.reject(error);
}
);
const checkAuth = () => {
const token = localStorage.getItem('admin_token');
const user = localStorage.getItem('admin_user');
if (token && user) {
isLoggedIn.value = true;
currentUser.value = JSON.parse(user);
return true;
}
return false;
};
const handleLogin = async () => {
if (!loginForm.userName || !loginForm.password) {
return ElementPlus.ElMessage.warning('请输入用户名和密码');
}
loggingIn.value = true;
try {
const res = await axios.post('/auth/login', loginForm);
const data = res.data;
if (data.role !== 'admin') {
throw new Error('只有管理员可以访问此后台');
}
localStorage.setItem('admin_token', data.accessToken);
localStorage.setItem('admin_user', JSON.stringify({ userName: loginForm.userName, role: data.role }));
isLoggedIn.value = true;
currentUser.value = { userName: loginForm.userName, role: data.role };
loginForm.password = '';
ElementPlus.ElMessage.success('登录成功');
refreshAll();
} catch (e) {
ElementPlus.ElMessage.error(e.message || '登录失败,请检查凭据');
} finally {
loggingIn.value = false;
}
};
const handleLogout = () => {
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
isLoggedIn.value = false;
currentUser.value = { userName: '', role: '' };
};
const fetchUsers = async () => {
if (!isLoggedIn.value) return;
loadingUsers.value = true;
try {
const res = await axios.get('/admin/users');
users.value = res.data;
} catch (e) {} finally {
loadingUsers.value = false;
}
};
const fetchSessions = async () => {
if (!isLoggedIn.value) return;
loadingSessions.value = true;
try {
const res = await axios.get('/admin/sessions');
sessions.value = res.data;
} catch (e) {} finally {
loadingSessions.value = false;
}
};
const fetchLogs = async () => {
if (!isLoggedIn.value) return;
loadingLogs.value = true;
try {
const res = await axios.get('/admin/audit-logs?count=100');
logs.value = res.data;
} catch (e) {} finally {
loadingLogs.value = false;
}
};
const handleTabSelect = (index) => {
activeTab.value = index;
if (index === 'users') fetchUsers();
if (index === 'sessions') fetchSessions();
if (index === 'logs') fetchLogs();
};
const handleCreateUser = async () => {
if (!newUser.userName || !newUser.password) {
return ElementPlus.ElMessage.warning('请填写完整信息');
}
creatingUser.value = true;
try {
await axios.post('/admin/users', newUser);
ElementPlus.ElMessage.success('创建用户成功');
showCreateUserDialog.value = false;
newUser.userName = '';
newUser.password = '';
fetchUsers();
} catch (e) {} finally {
creatingUser.value = false;
}
};
const handleResetPassword = async (user) => {
try {
const { value: newPass } = await ElementPlus.ElMessageBox.prompt(
`请输入用户 [${user.userName}] 的新密码`, '重置密码', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputType: 'password'
});
if (newPass) {
await axios.post(`/admin/users/${user.id}/reset-password`, { newPassword: newPass });
ElementPlus.ElMessage.success('重置成功');
}
} catch (e) {}
};
const handleDeleteUser = async (user) => {
try {
await ElementPlus.ElMessageBox.confirm(
`确定删除用户 [${user.userName}] 吗?此操作不可逆!`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
});
await axios.delete(`/admin/users/${user.id}`);
ElementPlus.ElMessage.success('删除成功');
fetchUsers();
} catch (e) {}
};
const handleRevokeSession = async (session) => {
try {
await axios.delete(`/admin/sessions/${session.id}`);
ElementPlus.ElMessage.success('已强制下线');
fetchSessions();
} catch (e) {}
};
const formatDate = (dateStr) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleString();
};
const refreshAll = () => {
fetchUsers();
fetchSessions();
fetchLogs();
};
onMounted(() => {
if (checkAuth()) {
refreshAll();
}
});
return {
activeTab, users, sessions, logs,
loadingUsers, loadingSessions, loadingLogs,
showCreateUserDialog, creatingUser, newUser,
isLoggedIn, loggingIn, loginForm, currentUser,
handleTabSelect, handleCreateUser, handleResetPassword, handleDeleteUser, handleRevokeSession,
handleLogin, handleLogout,
formatDate, fetchSessions, fetchLogs, refreshAll
};
}
}).use(ElementPlus).mount('#app');
</script>
</body>
</html>
+10
View File
@@ -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();
+30
View File
@@ -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<SecurityPolicyResponse> {
const response = await cloudClient.get<SecurityPolicyResponse>('/security/policy');
return response.data;
},
/**
* 执行二次认证(Step-up)。
*/
async stepUp(password: string): Promise<StepUpResponse> {
const response = await cloudClient.post<StepUpResponse>('/auth/step-up', { password });
return response.data;
},
};
export default cloudSyncApi;
@@ -47,6 +47,20 @@
<div class="hint">
已登录<span class="mono">{{ sessionSummary }}</span>
</div>
<div v-if="policy" class="policy-info">
<div class="policy-item">
<span class="label">落盘策略:</span>
<span :class="policy.allowPersist ? 'success' : 'warn'">
{{ policy.allowPersist ? '允许(本地持久化)' : '禁止(仅限内存)' }}
</span>
</div>
<div class="policy-item">
<span class="label">同步权限:</span>
<span :class="policy.allowSync ? 'success' : 'warn'">
{{ policy.allowSync ? '正常' : '已禁用' }}
</span>
</div>
</div>
<div class="button-row">
<button class="btn btn-secondary" @click="logout">登出</button>
<button class="btn btn-primary" :disabled="!localEnabled" @click="pullTasks">
@@ -86,13 +100,43 @@
<button class="btn btn-secondary" @click="close">关闭</button>
</div>
</div>
<!-- Step-up (2FA) Overlay -->
<div v-if="cloudSyncState.isSteppingUp" class="overlay step-up-overlay" @click.self="cancelStepUp">
<div class="dialog step-up-dialog">
<div class="dialog-header">
<h2>🔐 二次认证</h2>
</div>
<div class="dialog-body">
<div class="hint">当前操作属于高风险操作请验证您的登录密码</div>
<div class="form-row">
<input
v-model="stepUpPassword"
class="text-input"
type="password"
placeholder="请输入密码"
@keyup.enter="confirmStepUp"
/>
</div>
<div v-if="stepUpError" class="error-text">{{ stepUpError }}</div>
</div>
<div class="dialog-footer">
<button class="btn btn-secondary" @click="cancelStepUp">取消</button>
<button class="btn btn-primary" :disabled="!stepUpPassword || isSteppingUp" @click="confirmStepUp">
{{ isSteppingUp ? '验证中...' : '确认' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import CloudSyncStorage from '../services/cloudSyncStorage';
import cloudSyncApi from '../api/cloudSync';
import cloudSyncApi, { type SecurityPolicyResponse } from '../api/cloudSync';
import LocalStorageService from '../services/localStorageService';
import { cloudSyncState } from '../services/cloudSyncState';
type ProbeType = 'success' | 'warn' | 'error';
interface ProbeResult {
@@ -112,11 +156,33 @@ const userName = ref('');
const password = ref('');
const isLoggingIn = ref(false);
const policy = ref<SecurityPolicyResponse | null>(null);
const stepUpPassword = ref('');
const stepUpError = ref('');
const isSteppingUp = ref(false);
const loadLocalState = () => {
const settings = CloudSyncStorage.loadSettings();
localEnabled.value = settings.enabled;
serverUrlSaved.value = settings.serverUrl;
serverUrlInput.value = settings.serverUrl;
if (isLoggedIn.value) {
refreshPolicy();
}
};
const refreshPolicy = async () => {
try {
const p = await cloudSyncApi.getSecurityPolicy();
policy.value = p;
cloudSyncState.policy = p;
// 更新落盘策略
LocalStorageService.setAllowPersist(p.allowPersist);
} catch (err) {
console.error('Failed to fetch security policy:', err);
}
};
const isLoggedIn = computed(() => Boolean(CloudSyncStorage.loadSession()?.accessToken));
@@ -280,6 +346,7 @@ const login = async () => {
password: password.value,
});
showToast('登录成功', 'success');
await refreshPolicy();
emitCloudSyncStateChanged();
await pullTasks();
} finally {
@@ -289,10 +356,41 @@ const login = async () => {
const logout = () => {
cloudSyncApi.logout();
policy.value = null;
cloudSyncState.policy = { allowPersist: true, allowSync: true, requireSecondFactorFor: [] };
LocalStorageService.setAllowPersist(true); // 登出后重置
showToast('已登出', 'success');
emitCloudSyncStateChanged();
};
const confirmStepUp = async () => {
if (!stepUpPassword.value || isSteppingUp.value) return;
isSteppingUp.value = true;
stepUpError.value = '';
try {
await cloudSyncApi.stepUp(stepUpPassword.value);
showToast('二次认证成功', 'success');
cloudSyncState.isSteppingUp = false;
stepUpPassword.value = '';
// 如果有挂起的回调,执行它
if (cloudSyncState.onStepUpSuccess) {
cloudSyncState.onStepUpSuccess();
cloudSyncState.onStepUpSuccess = null;
}
} catch (err: any) {
stepUpError.value = '认证失败,请检查密码';
} finally {
isSteppingUp.value = false;
}
};
const cancelStepUp = () => {
cloudSyncState.isSteppingUp = false;
cloudSyncState.onStepUpSuccess = null;
stepUpPassword.value = '';
stepUpError.value = '';
};
const pullTasks = async () => {
const event = new CustomEvent('cloudSyncPullTasksRequested');
window.dispatchEvent(event);
@@ -310,6 +408,11 @@ function close() {
onMounted(() => {
window.addEventListener('openCloudSyncSettings', handleOpenSettings);
// 初始化:如果已登录,自动获取策略
if (isLoggedIn.value) {
refreshPolicy();
}
});
</script>
@@ -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;
@@ -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;
@@ -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) : {