diff --git a/CONSOLE_DEVELOPMENT.md b/CONSOLE_DEVELOPMENT.md new file mode 100644 index 0000000..4f0b8f4 --- /dev/null +++ b/CONSOLE_DEVELOPMENT.md @@ -0,0 +1,316 @@ +# 风铃认证中心 - 管理端开发完成 + +## 项目概述 + +本项目为风铃认证中心提供完整的 Web 管理界面和后端 API。 + +## 技术栈 + +### 前端 +- Vue 3 + TypeScript +- Vite +- Element Plus UI 框架 +- Pinia 状态管理 +- Vue Router 路由 +- Axios HTTP 客户端 + +### 后端 +- ASP.NET Core 10.0 +- Entity Framework Core 10.0 +- PostgreSQL 数据库 +- OpenIddict OAuth2/OIDC 服务 +- Serilog 日志 +- OpenTelemetry 可观测性 + +## 完成的功能 + +### 前端页面 + +#### 1. 仪表盘(Dashboard) +- 统计卡片:用户数、租户数、OAuth应用数、今日访问 +- 最近活动时间线 +- 系统信息展示 +- 位置:`src/views/Dashboard/Dashboard.vue` + +#### 2. 用户管理 +- 用户列表(分页、搜索) +- 添加/编辑用户 +- 角色分配 +- 重置密码 +- 删除用户 +- 位置:`src/views/Users/UserList.vue` + +#### 3. 角色管理 +- 角色列表(分页、搜索) +- 添加/编辑角色 +- 权限配置 +- 查看角色用户 +- 移除用户角色 +- 位置:`src/views/Users/RoleList.vue` + +#### 4. 租户管理 +- 租户列表(分页、搜索) +- 添加/编辑租户 +- 租户设置(注册限制、密码策略、会话超时) +- 查看租户用户和角色 +- 位置:`src/views/Users/TenantList.vue` + +#### 5. OAuth 应用管理 +- 应用列表(分页、搜索) +- 添加/编辑应用 +- 完整配置(重定向URI、授权类型、权限范围等) +- 查看密钥 +- 位置:`src/views/OAuth/ClientList.vue` + +#### 6. 访问日志 +- 日志列表(多条件筛选) +- 日志详情查看 +- 导出 CSV +- 位置:`src/views/Audit/AccessLog.vue` + +#### 7. 审计日志 +- 日志列表(多条件筛选) +- 日志详情查看(包含变更前/后数据) +- 导出 CSV +- 位置:`src/views/Audit/AuditLog.vue` + +### 后端 API + +#### 控制器 + +1. **AuthController** - 认证端点 + - POST /connect/token - 登录(密码模式) + - POST /connect/token/refresh - 刷新令牌 + - POST /connect/revoke - 撤销令牌 + +2. **UsersController** - 用户管理 + - GET /api/users - 获取用户列表(分页、搜索) + - GET /api/users/{id} - 获取单个用户 + - POST /api/users - 创建用户 + - PUT /api/users/{id} - 更新用户 + - PUT /api/users/{id}/password - 重置密码 + - DELETE /api/users/{id} - 删除用户 + +3. **RolesController** - 角色管理 + - GET /api/roles - 获取角色列表(分页、搜索) + - GET /api/roles/{id} - 获取单个角色 + - GET /api/roles/{id}/users - 获取角色用户 + - POST /api/roles - 创建角色 + - PUT /api/roles/{id} - 更新角色 + - DELETE /api/roles/{id} - 删除角色 + - DELETE /api/roles/{id}/users/{userId} - 移除用户角色 + +4. **TenantsController** - 租户管理 + - GET /api/tenants - 获取租户列表(分页、搜索) + - GET /api/tenants/{id} - 获取单个租户 + - GET /api/tenants/{tenantId}/users - 获取租户用户 + - GET /api/tenants/{tenantId}/roles - 获取租户角色 + - GET /api/tenants/{tenantId}/settings - 获取租户设置 + - PUT /api/tenants/{tenantId}/settings - 更新租户设置 + - POST /api/tenants - 创建租户 + - PUT /api/tenants/{id} - 更新租户 + - DELETE /api/tenants/{id} - 删除租户 + +5. **OAuthClientsController** - OAuth 应用管理 + - GET /api/oauthclients - 获取应用列表(分页、搜索) + - GET /api/oauthclients/{id} - 获取单个应用 + - GET /api/oauthclients/{id}/secret - 获取应用密钥 + - POST /api/oauthclients - 创建应用 + - PUT /api/oauthclients/{id} - 更新应用 + - DELETE /api/oauthclients/{id} - 删除应用 + +6. **AccessLogsController** - 访问日志 + - GET /api/access-logs - 获取日志列表(分页、筛选) + - GET /api/access-logs/export - 导出 CSV + +7. **AuditLogsController** - 审计日志 + - GET /api/audit-logs - 获取日志列表(分页、筛选) + - GET /api/audit-logs/export - 导出 CSV + +8. **StatsController** - 统计数据 + - GET /api/stats/dashboard - 仪表盘统计数据 + - GET /api/stats/system - 系统统计信息 + +9. **HealthCheckController** - 健康检查 + - GET /health - 健康检查端点 + +### 数据模型 + +#### 核心模型 + +1. **ApplicationUser** - 用户 + - 继承自 IdentityUser + - RealName, Phone, TenantId, CreatedTime, UpdatedTime, IsDeleted + +2. **ApplicationRole** - 角色 + - 继承自 IdentityRole + - Description, DisplayName, TenantId, IsSystem, Permissions, CreatedTime + +3. **Tenant** - 租户 + - Id, TenantId, Name, ContactName, ContactEmail, ContactPhone + - MaxUsers, Description, Status, ExpiresAt, CreatedAt, UpdatedAt, IsDeleted + +4. **OAuthApplication** - OAuth 应用 + - Id, ClientId, ClientSecret, DisplayName + - RedirectUris, PostLogoutRedirectUris, Scopes, GrantTypes + - ClientType, ConsentType, Status, Description, CreatedAt, UpdatedAt + +5. **AccessLog** - 访问日志 + - UserName, TenantId, Action, Resource, Method, IpAddress + - UserAgent, Status, Duration, RequestData, ResponseData, ErrorMessage + - CreatedAt + +6. **AuditLog** - 审计日志 + - Operator, TenantId, Operation, Action + - TargetType, TargetId, TargetName, IpAddress, Description + - OldValue, NewValue, ErrorMessage, Status, CreatedAt + +### 数据库配置 + +- **数据库类型**: PostgreSQL +- **连接字符串**: `Host=192.168.100.10;Port=5432;Database=fengling_auth;Username=movingsam;Password=sl52788542` +- **迁移**: + - InitialCreate - 初始化表结构 + - AddOAuthApplications - 添加 OAuth 应用表 + - AddTenantAndLogs - 添加租户和日志表 + - AddOAuthDescription - 添加 OAuth 描述字段 + +### 初始数据 + +SeedData 初始化以下数据: + +#### 默认租户 +- TenantId: default +- Name: 默认租户 +- MaxUsers: 1000 + +#### 系统角色 +1. **Admin** - 管理员 + - 所有权限 + - IsSystem: true + +2. **User** - 普通用户 + - user.view 权限 + - IsSystem: true + +#### 默认用户 +1. **admin** + - 密码: Admin@123 + - 角色: Admin + +2. **testuser** + - 密码: Test@123 + - 角色: User + +#### OAuth 应用 +- **fengling-console** - 风铃运管中心 + - ClientSecret: console-secret-change-in-production + - 授权类型: authorization_code, refresh_token + +## 运行说明 + +### 后端(AuthService) + +```bash +cd src/Fengling.AuthService +dotnet build +dotnet run +``` + +服务地址: http://localhost:5000 +API 文档: http://localhost:5000/swagger +健康检查: http://localhost:5000/health + +### 前端(Console.Web) + +```bash +cd src/Fengling.Console.Web +npm install +npm run dev +``` + +开发地址: http://localhost:5173 + +生产构建: +```bash +npm run build +``` + +## API 代理配置 + +Vite 开发服务器配置了 API 代理: + +- `/api/auth/*` → `http://localhost:5000` (AuthService) +- `/api/gateway/*` → `http://localhost:5001` (YarpGateway) + +## 默认登录 + +- 用户名: admin +- 密码: Admin@123 + +## 文件结构 + +``` +Fengling.Refactory.Buiding/ +├── src/ +│ ├── Fengling.AuthService/ # 后端认证服务 +│ │ ├── Controllers/ # API 控制器 +│ │ ├── Data/ # 数据库上下文和种子数据 +│ │ ├── Models/ # 数据模型 +│ │ ├── Configuration/ # 配置类 +│ │ └── Migrations/ # 数据库迁移 +│ │ +│ └── Fengling.Console.Web/ # 前端管理界面 +│ ├── src/ +│ │ ├── api/ # API 调用封装 +│ │ ├── stores/ # Pinia 状态管理 +│ │ ├── router/ # Vue Router 路由 +│ │ └── views/ # 页面组件 +│ │ ├── Auth/ # 认证页面 +│ │ ├── Dashboard/ # 仪表盘 +│ │ ├── Users/ # 用户/角色/租户管理 +│ │ ├── OAuth/ # OAuth 应用管理 +│ │ └── Audit/ # 日志管理 +│ └── dist/ # 生产构建输出 +│ +└── docs/ # 项目文档 +``` + +## 安全建议 + +1. **生产环境修改**: + - 更改默认管理员密码 + - 修改 OAuth 应用密钥 + - 配置 HTTPS + - 限制数据库访问 + +2. **密码策略**: + - 至少 8 位 + - 包含字母和数字 + - 可根据租户设置自定义策略 + +## 后续优化建议 + +1. **前端**: + - 添加更多图表可视化 + - 实现前端国际化 + - 添加单元测试 + +2. **后端**: + - 添加 API 限流 + - 实现缓存策略 + - 添加更多日志级别 + - 完善错误处理 + +3. **功能**: + - OAuth 授权码流程完善 + - 双因素认证(2FA) + - 用户自助服务(找回密码、注册) + - API 权限细粒度控制 + +## 版本信息 + +- 前端版本: v1.0.0 +- 后端版本: v1.0.0 +- .NET 版本: 10.0.2 +- Node.js 版本: 推荐 18+ diff --git a/Fengling.Refactory.Buiding.sln b/Fengling.Refactory.Buiding.sln index 7f8ceca..c05165a 100644 --- a/Fengling.Refactory.Buiding.sln +++ b/Fengling.Refactory.Buiding.sln @@ -7,6 +7,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YarpGateway", "src\YarpGateway\YarpGateway.csproj", "{8DDFE39A-06AE-4C02-BA80-27F0C809E959}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fengling.AuthService", "src\Fengling.AuthService\Fengling.AuthService.csproj", "{469FA168-1656-483D-A40D-072FFE8C5E33}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -29,11 +31,24 @@ Global {8DDFE39A-06AE-4C02-BA80-27F0C809E959}.Release|x64.Build.0 = Release|Any CPU {8DDFE39A-06AE-4C02-BA80-27F0C809E959}.Release|x86.ActiveCfg = Release|Any CPU {8DDFE39A-06AE-4C02-BA80-27F0C809E959}.Release|x86.Build.0 = Release|Any CPU + {469FA168-1656-483D-A40D-072FFE8C5E33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {469FA168-1656-483D-A40D-072FFE8C5E33}.Debug|Any CPU.Build.0 = Debug|Any CPU + {469FA168-1656-483D-A40D-072FFE8C5E33}.Debug|x64.ActiveCfg = Debug|Any CPU + {469FA168-1656-483D-A40D-072FFE8C5E33}.Debug|x64.Build.0 = Debug|Any CPU + {469FA168-1656-483D-A40D-072FFE8C5E33}.Debug|x86.ActiveCfg = Debug|Any CPU + {469FA168-1656-483D-A40D-072FFE8C5E33}.Debug|x86.Build.0 = Debug|Any CPU + {469FA168-1656-483D-A40D-072FFE8C5E33}.Release|Any CPU.ActiveCfg = Release|Any CPU + {469FA168-1656-483D-A40D-072FFE8C5E33}.Release|Any CPU.Build.0 = Release|Any CPU + {469FA168-1656-483D-A40D-072FFE8C5E33}.Release|x64.ActiveCfg = Release|Any CPU + {469FA168-1656-483D-A40D-072FFE8C5E33}.Release|x64.Build.0 = Release|Any CPU + {469FA168-1656-483D-A40D-072FFE8C5E33}.Release|x86.ActiveCfg = Release|Any CPU + {469FA168-1656-483D-A40D-072FFE8C5E33}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {8DDFE39A-06AE-4C02-BA80-27F0C809E959} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {469FA168-1656-483D-A40D-072FFE8C5E33} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/LAYOUT_FIX.md b/LAYOUT_FIX.md new file mode 100644 index 0000000..be7a4ae --- /dev/null +++ b/LAYOUT_FIX.md @@ -0,0 +1,72 @@ +# 风铃认证中心 - 布局和路由修复 + +## 修复的问题 + +1. **菜单点击不跳转** - 添加了 `router.push({ name: index })` 到 `handleMenuSelect` +2. **App.vue 缺少 router-view** - 更新为包含 `` 和基础样式 +3. **面包屑显示** - 优化为只在非 Dashboard 页面显示 +4. **内容区域样式** - 添加 `content-wrapper` 包裹,优化布局 + +## 现在的布局结构 + +``` +App.vue +└── + ├── Login.vue (路径: /login) + ├── Callback.vue (路径: /auth/callback) + └── Dashboard.vue (路径: / 及其子路由) + ├── 侧边栏 (el-aside) + └── 主内容区 (el-main) + ├── 面包屑 (可选) + └── (嵌套子路由) + ├── Dashboard/Dashboard.vue + ├── Users/UserList.vue + ├── Users/RoleList.vue + ├── Users/TenantList.vue + ├── OAuth/ClientList.vue + ├── Audit/AccessLog.vue + └── Audit/AuditLog.vue +``` + +## 运行项目 + +### 启动后端 +```bash +cd src/Fengling.AuthService +dotnet run +``` + +### 启动前端 +```bash +cd src/Fengling.Console.Web +npm run dev +``` + +访问: http://localhost:5173 + +默认登录: +- 用户名: admin +- 密码: Admin@123 + +## 功能测试清单 + +- [ ] 登录页面正常显示 +- [ ] 登录成功后跳转到仪表盘 +- [ ] 侧边栏菜单正常显示 +- [ ] 点击菜单项正确跳转 +- [ ] 当前菜单项高亮显示 +- [ ] 面包屑导航正确显示 +- [ ] 各个管理页面正常加载 +- [ ] 退出登录功能正常 + +## 页面路由 + +| 路径 | 名称 | 页面 | +|------|------|------| +| / | Dashboard | 仪表盘 | +| /users | UserList | 用户列表 | +| /roles | RoleList | 角色管理 | +| /tenants | TenantList | 租户管理 | +| /oauth/clients | OAuthClients | OAuth 应用 | +| /logs/access | AccessLog | 访问日志 | +| /logs/audit | AuditLog | 审计日志 | diff --git a/src/Fengling.AuthService/Configuration/OpenIddictSetup.cs b/src/Fengling.AuthService/Configuration/OpenIddictSetup.cs index 0e73445..8d5abcd 100644 --- a/src/Fengling.AuthService/Configuration/OpenIddictSetup.cs +++ b/src/Fengling.AuthService/Configuration/OpenIddictSetup.cs @@ -1,6 +1,6 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.DependencyInjection; -using Microsoft.IdentityModel.Tokens; -using OpenIddict.Validation.AspNetCore; namespace Fengling.AuthService.Configuration; @@ -11,42 +11,41 @@ public static class OpenIddictSetup IConfiguration configuration ) { - services - .AddOpenIddict() - .AddCore(options => - { - options.UseEntityFrameworkCore().UseDbContext(); - }) - .AddServer(options => - { - options.SetIssuer( - configuration["OpenIddict:Issuer"] ?? "https://auth.fengling.local" - ); + var isTesting = configuration.GetValue("Testing", false); - options.AddDevelopmentEncryptionCertificate().AddDevelopmentSigningCertificate(); + var builder = services.AddOpenIddict(); - options - .AllowAuthorizationCodeFlow() - .AllowPasswordFlow() - .AllowRefreshTokenFlow() - .RequireProofKeyForCodeExchange(); + builder.AddCore(options => + { + options.UseEntityFrameworkCore().UseDbContext(); + }); + + if (!isTesting) + { + builder.AddServer(options => + { + options.SetIssuer(configuration["OpenIddict:Issuer"] ?? "https://auth.fengling.local"); + + options.AddDevelopmentEncryptionCertificate() + .AddDevelopmentSigningCertificate(); + + options.AllowAuthorizationCodeFlow() + .AllowPasswordFlow() + .AllowRefreshTokenFlow() + .RequireProofKeyForCodeExchange(); options.RegisterScopes("api", "offline_access"); - - options.UseAspNetCore(); - }) - .AddValidation(options => - { - options.UseLocalServer(); - options.UseAspNetCore(); }); + } + + builder.AddValidation(options => + { + options.UseLocalServer(); + }); services.AddAuthentication(options => { - options.DefaultAuthenticateScheme = - OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = - OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; }); return services; diff --git a/src/Fengling.AuthService/Controllers/AccessLogsController.cs b/src/Fengling.AuthService/Controllers/AccessLogsController.cs new file mode 100644 index 0000000..3abe130 --- /dev/null +++ b/src/Fengling.AuthService/Controllers/AccessLogsController.cs @@ -0,0 +1,158 @@ +using Fengling.AuthService.Data; +using Fengling.AuthService.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Fengling.AuthService.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class AccessLogsController : ControllerBase +{ + private readonly ApplicationDbContext _context; + private readonly ILogger _logger; + + public AccessLogsController( + ApplicationDbContext context, + ILogger logger) + { + _context = context; + _logger = logger; + } + + [HttpGet] + public async Task> GetAccessLogs( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] string? userName = null, + [FromQuery] string? tenantId = null, + [FromQuery] string? action = null, + [FromQuery] string? status = null, + [FromQuery] DateTime? startTime = null, + [FromQuery] DateTime? endTime = null) + { + var query = _context.AccessLogs.AsQueryable(); + + if (!string.IsNullOrEmpty(userName)) + { + query = query.Where(l => l.UserName != null && l.UserName.Contains(userName)); + } + + if (!string.IsNullOrEmpty(tenantId)) + { + query = query.Where(l => l.TenantId == tenantId); + } + + if (!string.IsNullOrEmpty(action)) + { + query = query.Where(l => l.Action == action); + } + + if (!string.IsNullOrEmpty(status)) + { + query = query.Where(l => l.Status == status); + } + + if (startTime.HasValue) + { + query = query.Where(l => l.CreatedAt >= startTime.Value); + } + + if (endTime.HasValue) + { + query = query.Where(l => l.CreatedAt <= endTime.Value); + } + + var totalCount = await query.CountAsync(); + var logs = await query + .OrderByDescending(l => l.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + var result = logs.Select(l => new + { + id = l.Id, + userName = l.UserName, + tenantId = l.TenantId, + action = l.Action, + resource = l.Resource, + method = l.Method, + ipAddress = l.IpAddress, + userAgent = l.UserAgent, + status = l.Status, + duration = l.Duration, + requestData = l.RequestData, + responseData = l.ResponseData, + errorMessage = l.ErrorMessage, + createdAt = l.CreatedAt, + }); + + return Ok(new + { + items = result, + totalCount, + page, + pageSize + }); + } + + [HttpGet("export")] + public async Task ExportAccessLogs( + [FromQuery] string? userName = null, + [FromQuery] string? tenantId = null, + [FromQuery] string? action = null, + [FromQuery] string? status = null, + [FromQuery] DateTime? startTime = null, + [FromQuery] DateTime? endTime = null) + { + var query = _context.AccessLogs.AsQueryable(); + + if (!string.IsNullOrEmpty(userName)) + { + query = query.Where(l => l.UserName != null && l.UserName.Contains(userName)); + } + + if (!string.IsNullOrEmpty(tenantId)) + { + query = query.Where(l => l.TenantId == tenantId); + } + + if (!string.IsNullOrEmpty(action)) + { + query = query.Where(l => l.Action == action); + } + + if (!string.IsNullOrEmpty(status)) + { + query = query.Where(l => l.Status == status); + } + + if (startTime.HasValue) + { + query = query.Where(l => l.CreatedAt >= startTime.Value); + } + + if (endTime.HasValue) + { + query = query.Where(l => l.CreatedAt <= endTime.Value); + } + + var logs = await query + .OrderByDescending(l => l.CreatedAt) + .Take(10000) + .ToListAsync(); + + var csv = new System.Text.StringBuilder(); + csv.AppendLine("ID,UserName,TenantId,Action,Resource,Method,IpAddress,UserAgent,Status,Duration,CreatedAt"); + + foreach (var log in logs) + { + csv.AppendLine($"{log.Id},{log.UserName},{log.TenantId},{log.Action},{log.Resource},{log.Method},{log.IpAddress},\"{log.UserAgent}\",{log.Status},{log.Duration},{log.CreatedAt:yyyy-MM-dd HH:mm:ss}"); + } + + return File(System.Text.Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", $"access-logs-{DateTime.UtcNow:yyyyMMdd}.csv"); + } +} diff --git a/src/Fengling.AuthService/Controllers/AccountController.cs b/src/Fengling.AuthService/Controllers/AccountController.cs new file mode 100644 index 0000000..d8ee7cb --- /dev/null +++ b/src/Fengling.AuthService/Controllers/AccountController.cs @@ -0,0 +1,119 @@ +using Fengling.AuthService.Data; +using Fengling.AuthService.Models; +using Fengling.AuthService.ViewModels; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace Fengling.AuthService.Controllers; + +[Route("account")] +public class AccountController : Controller +{ + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly ILogger _logger; + + public AccountController( + UserManager userManager, + SignInManager signInManager, + ILogger logger) + { + _userManager = userManager; + _signInManager = signInManager; + _logger = logger; + } + + [HttpGet("login")] + public IActionResult Login(string returnUrl = "/") + { + return View(new LoginInputModel { ReturnUrl = returnUrl }); + } + + [HttpPost("login")] + [ValidateAntiForgeryToken] + public async Task Login(LoginInputModel model) + { + if (!ModelState.IsValid) + { + return View(model); + } + + var user = await _userManager.FindByNameAsync(model.Username); + if (user == null || user.IsDeleted) + { + ModelState.AddModelError(string.Empty, "用户名或密码错误"); + return View(model); + } + + var result = await _signInManager.PasswordSignInAsync(user, model.Password, model.RememberMe, false); + if (!result.Succeeded) + { + if (result.IsLockedOut) + { + ModelState.AddModelError(string.Empty, "账号已被锁定"); + } + else + { + ModelState.AddModelError(string.Empty, "用户名或密码错误"); + } + return View(model); + } + + return LocalRedirect(model.ReturnUrl); + } + + [HttpGet("register")] + public IActionResult Register(string returnUrl = "/") + { + return View(new RegisterViewModel { ReturnUrl = returnUrl }); + } + + [HttpPost("register")] + [ValidateAntiForgeryToken] + public async Task Register(RegisterViewModel model) + { + if (!ModelState.IsValid) + { + return View(model); + } + + var user = new ApplicationUser + { + UserName = model.Username, + Email = model.Email, + NormalizedUserName = model.Username.ToUpper(), + NormalizedEmail = model.Email.ToUpper() + }; + + var result = await _userManager.CreateAsync(user, model.Password); + if (!result.Succeeded) + { + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + return View(model); + } + + await _signInManager.SignInAsync(user, isPersistent: false); + return LocalRedirect(model.ReturnUrl); + } + + [HttpGet("profile")] + [HttpGet("settings")] + [HttpGet("logout")] + public IActionResult NotImplemented() + { + return RedirectToAction("Index", "Dashboard"); + } + + [HttpPost("logout")] + [ValidateAntiForgeryToken] + public async Task LogoutPost() + { + await _signInManager.SignOutAsync(); + return Redirect("/"); + } +} diff --git a/src/Fengling.AuthService/Controllers/AuditLogsController.cs b/src/Fengling.AuthService/Controllers/AuditLogsController.cs new file mode 100644 index 0000000..f009aca --- /dev/null +++ b/src/Fengling.AuthService/Controllers/AuditLogsController.cs @@ -0,0 +1,159 @@ +using Fengling.AuthService.Data; +using Fengling.AuthService.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Fengling.AuthService.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class AuditLogsController : ControllerBase +{ + private readonly ApplicationDbContext _context; + private readonly ILogger _logger; + + public AuditLogsController( + ApplicationDbContext context, + ILogger logger) + { + _context = context; + _logger = logger; + } + + [HttpGet] + public async Task> GetAuditLogs( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] string? operatorName = null, + [FromQuery] string? tenantId = null, + [FromQuery] string? operation = null, + [FromQuery] string? action = null, + [FromQuery] DateTime? startTime = null, + [FromQuery] DateTime? endTime = null) + { + var query = _context.AuditLogs.AsQueryable(); + + if (!string.IsNullOrEmpty(operatorName)) + { + query = query.Where(l => l.Operator.Contains(operatorName)); + } + + if (!string.IsNullOrEmpty(tenantId)) + { + query = query.Where(l => l.TenantId == tenantId); + } + + if (!string.IsNullOrEmpty(operation)) + { + query = query.Where(l => l.Operation == operation); + } + + if (!string.IsNullOrEmpty(action)) + { + query = query.Where(l => l.Action == action); + } + + if (startTime.HasValue) + { + query = query.Where(l => l.CreatedAt >= startTime.Value); + } + + if (endTime.HasValue) + { + query = query.Where(l => l.CreatedAt <= endTime.Value); + } + + var totalCount = await query.CountAsync(); + var logs = await query + .OrderByDescending(l => l.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + var result = logs.Select(l => new + { + id = l.Id, + @operator = l.Operator, + tenantId = l.TenantId, + operation = l.Operation, + action = l.Action, + targetType = l.TargetType, + targetId = l.TargetId, + targetName = l.TargetName, + ipAddress = l.IpAddress, + description = l.Description, + oldValue = l.OldValue, + newValue = l.NewValue, + errorMessage = l.ErrorMessage, + status = l.Status, + createdAt = l.CreatedAt, + }); + + return Ok(new + { + items = result, + totalCount, + page, + pageSize + }); + } + + [HttpGet("export")] + public async Task ExportAuditLogs( + [FromQuery] string? operatorName = null, + [FromQuery] string? tenantId = null, + [FromQuery] string? operation = null, + [FromQuery] string? action = null, + [FromQuery] DateTime? startTime = null, + [FromQuery] DateTime? endTime = null) + { + var query = _context.AuditLogs.AsQueryable(); + + if (!string.IsNullOrEmpty(operatorName)) + { + query = query.Where(l => l.Operator.Contains(operatorName)); + } + + if (!string.IsNullOrEmpty(tenantId)) + { + query = query.Where(l => l.TenantId == tenantId); + } + + if (!string.IsNullOrEmpty(operation)) + { + query = query.Where(l => l.Operation == operation); + } + + if (!string.IsNullOrEmpty(action)) + { + query = query.Where(l => l.Action == action); + } + + if (startTime.HasValue) + { + query = query.Where(l => l.CreatedAt >= startTime.Value); + } + + if (endTime.HasValue) + { + query = query.Where(l => l.CreatedAt <= endTime.Value); + } + + var logs = await query + .OrderByDescending(l => l.CreatedAt) + .Take(10000) + .ToListAsync(); + + var csv = new System.Text.StringBuilder(); + csv.AppendLine("ID,Operator,TenantId,Operation,Action,TargetType,TargetId,TargetName,IpAddress,Description,Status,CreatedAt"); + + foreach (var log in logs) + { + csv.AppendLine($"{log.Id},{log.Operator},{log.TenantId},{log.Operation},{log.Action},{log.TargetType},{log.TargetId},{log.TargetName},{log.IpAddress},\"{log.Description}\",{log.Status},{log.CreatedAt:yyyy-MM-dd HH:mm:ss}"); + } + + return File(System.Text.Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", $"audit-logs-{DateTime.UtcNow:yyyyMMdd}.csv"); + } +} diff --git a/src/Fengling.AuthService/Controllers/AuthController.cs b/src/Fengling.AuthService/Controllers/AuthController.cs deleted file mode 100644 index c57d7d7..0000000 --- a/src/Fengling.AuthService/Controllers/AuthController.cs +++ /dev/null @@ -1,90 +0,0 @@ -using Fengling.AuthService.DTOs; -using Fengling.AuthService.Models; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Mvc; -using OpenIddict.Abstractions; -using OpenIddict.Server.AspNetCore; -using System.Security.Claims; -using static OpenIddict.Abstractions.OpenIddictConstants; - -namespace Fengling.AuthService.Controllers; - -[ApiController] -[Route("api/[controller]")] -public class AuthController : ControllerBase -{ - private readonly SignInManager _signInManager; - private readonly UserManager _userManager; - private readonly IOpenIddictApplicationManager _applicationManager; - private readonly IOpenIddictAuthorizationManager _authorizationManager; - private readonly IOpenIddictScopeManager _scopeManager; - private readonly ILogger _logger; - - public AuthController( - SignInManager signInManager, - UserManager userManager, - IOpenIddictApplicationManager applicationManager, - IOpenIddictAuthorizationManager authorizationManager, - IOpenIddictScopeManager scopeManager, - ILogger logger) - { - _signInManager = signInManager; - _userManager = userManager; - _applicationManager = applicationManager; - _authorizationManager = authorizationManager; - _scopeManager = scopeManager; - _logger = logger; - } - - [HttpPost("login")] - public async Task Login([FromBody] LoginRequest request) - { - var user = await _userManager.FindByNameAsync(request.UserName); - if (user == null || user.IsDeleted) - { - return Unauthorized(new { error = "用户不存在" }); - } - - if (user.TenantId != request.TenantId) - { - return Unauthorized(new { error = "租户不匹配" }); - } - - var result = await _signInManager.CheckPasswordSignInAsync(user, request.Password, false); - if (!result.Succeeded) - { - return Unauthorized(new { error = "用户名或密码错误" }); - } - - var token = await GenerateTokenAsync(user); - return Ok(token); - } - - private async Task GenerateTokenAsync(ApplicationUser user) - { - var claims = new List - { - new(Claims.Subject, user.Id.ToString()), - new(Claims.Name, user.UserName ?? string.Empty), - new(Claims.Email, user.Email ?? string.Empty), - new("tenant_id", user.TenantId.ToString()) - }; - - var roles = await _userManager.GetRolesAsync(user); - foreach (var role in roles) - { - claims.Add(new Claim(Claims.Role, role)); - } - - var identity = new System.Security.Claims.ClaimsIdentity(claims, "Server"); - var principal = new System.Security.Claims.ClaimsPrincipal(identity); - - return new LoginResponse - { - AccessToken = "token-placeholder", - RefreshToken = "refresh-placeholder", - ExpiresIn = 3600, - TokenType = "Bearer" - }; - } -} diff --git a/src/Fengling.AuthService/Controllers/AuthorizationController.cs b/src/Fengling.AuthService/Controllers/AuthorizationController.cs new file mode 100644 index 0000000..a1baff4 --- /dev/null +++ b/src/Fengling.AuthService/Controllers/AuthorizationController.cs @@ -0,0 +1,217 @@ +using Fengling.AuthService.Data; +using Fengling.AuthService.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using System.Security.Claims; +using Fengling.AuthService.ViewModels; +using Microsoft.AspNetCore; +using Microsoft.Extensions.Primitives; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Fengling.AuthService.Controllers; + +[ApiController] +[Route("connect")] +public class AuthorizationController( + IOpenIddictApplicationManager applicationManager, + IOpenIddictAuthorizationManager authorizationManager, + IOpenIddictScopeManager scopeManager, + SignInManager signInManager, + UserManager userManager, + ILogger logger) + : Controller +{ + + [HttpGet("authorize")] + [HttpPost("authorize")] + public async Task Authorize() + { + var request = HttpContext.GetOpenIddictServerRequest() ?? + throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); + + // If prompt=login was specified by the client application, + // immediately return the user agent to the login page. + if (request.HasPromptValue(OpenIddictConstants.PromptValues.Login)) + { + // To avoid endless login -> authorization redirects, the prompt=login flag + // is removed from the authorization request payload before redirecting the user. + var prompt = string.Join(" ", request.GetPromptValues().Remove(OpenIddictConstants.PromptValues.Login)); + + var parameters = Request.HasFormContentType + ? Request.Form.Where(parameter => parameter.Key != OpenIddictConstants.Parameters.Prompt).ToList() + : Request.Query.Where(parameter => parameter.Key != OpenIddictConstants.Parameters.Prompt).ToList(); + + parameters.Add(KeyValuePair.Create(OpenIddictConstants.Parameters.Prompt, new StringValues(prompt))); + + return Challenge( + authenticationSchemes: IdentityConstants.ApplicationScheme, + properties: new AuthenticationProperties + { + RedirectUri = Request.PathBase + Request.Path + QueryString.Create(parameters) + }); + } + + // Retrieve the user principal stored in the authentication cookie. + // If a max_age parameter was provided, ensure that the cookie is not too old. + // If the user principal can't be extracted or the cookie is too old, redirect the user to the login page. + var result = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme); + if (result is not { Succeeded: true } || (request.MaxAge != null && result.Properties?.IssuedUtc != null && + DateTimeOffset.UtcNow - result.Properties.IssuedUtc > + TimeSpan.FromSeconds(request.MaxAge.Value))) + { + // If the client application requested promptless authentication, + // return an error indicating that the user is not logged in. + if (request.HasPromptValue(OpenIddictConstants.PromptValues.None)) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = + OpenIddictConstants.Errors.LoginRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is not logged in." + }!)); + } + + return Challenge( + authenticationSchemes: IdentityConstants.ApplicationScheme, + properties: new AuthenticationProperties + { + RedirectUri = Request.PathBase + Request.Path + QueryString.Create( + Request.HasFormContentType ? Request.Form.ToList() : Request.Query.ToList()) + }); + } + + // Retrieve the profile of the logged in user. + var user = await userManager.GetUserAsync(result.Principal) ?? + throw new InvalidOperationException("The user details cannot be retrieved."); + + // Retrieve the application details from the database. + var application = await applicationManager.FindByClientIdAsync(request.ClientId!) ?? + throw new InvalidOperationException( + "Details concerning the calling client application cannot be found."); + + // Retrieve the permanent authorizations associated with the user and the calling client application. + var authorizations = await authorizationManager.FindAsync( + subject: await userManager.GetUserIdAsync(user), + client: (await applicationManager.GetIdAsync(application))!, + status: OpenIddictConstants.Statuses.Valid, + type: OpenIddictConstants.AuthorizationTypes.Permanent, + scopes: request.GetScopes()).ToListAsync(); + + switch (await applicationManager.GetConsentTypeAsync(application)) + { + // If the consent is external (e.g when authorizations are granted by a sysadmin), + // immediately return an error if no authorization can be found in the database. + case OpenIddictConstants.ConsentTypes.External when !authorizations.Any(): + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = + OpenIddictConstants.Errors.ConsentRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The logged in user is not allowed to access this client application." + }!)); + + // If the consent is implicit or if an authorization was found, + // return an authorization response without displaying the consent form. + case OpenIddictConstants.ConsentTypes.Implicit: + case OpenIddictConstants.ConsentTypes.External when authorizations.Any(): + case OpenIddictConstants.ConsentTypes.Explicit + when authorizations.Any() && !request.HasPromptValue(OpenIddictConstants.PromptValues.Consent): + var principal = await signInManager.CreateUserPrincipalAsync(user); + + // Note: in this sample, the granted scopes match the requested scope + // but you may want to allow the user to uncheck specific scopes. + // For that, simply restrict the list of scopes before calling SetScopes. + principal.SetScopes(request.GetScopes()); + principal.SetResources(await scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync()); + + // Automatically create a permanent authorization to avoid requiring explicit consent + // for future authorization or token requests containing the same scopes. + var authorization = authorizations.LastOrDefault(); + if (authorization == null) + { + authorization = await authorizationManager.CreateAsync( + principal: principal, + subject: await userManager.GetUserIdAsync(user), + client: (await applicationManager.GetIdAsync(application))!, + type: OpenIddictConstants.AuthorizationTypes.Permanent, + scopes: principal.GetScopes()); + } + + principal.SetAuthorizationId(await authorizationManager.GetIdAsync(authorization)); + + foreach (var claim in principal.Claims) + { + claim.SetDestinations(GetDestinations(claim, principal)); + } + + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + + // At this point, no authorization was found in the database and an error must be returned + // if the client application specified prompt=none in the authorization request. + case OpenIddictConstants.ConsentTypes.Explicit when request.HasPromptValue(OpenIddictConstants.PromptValues.None): + case OpenIddictConstants.ConsentTypes.Systematic when request.HasPromptValue(OpenIddictConstants.PromptValues.None): + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = + OpenIddictConstants.Errors.ConsentRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "Interactive user consent is required." + }!)); + + // In every other case, render the consent form. + default: + return View(new AuthorizeViewModel(await applicationManager.GetDisplayNameAsync(application),request.Scope)); + } + } + + private IEnumerable GetDestinations(Claim claim, ClaimsPrincipal principal) + { + // Note: by default, claims are NOT automatically included in the access and identity tokens. + // To allow OpenIddict to serialize them, you must attach them a destination, that specifies + // whether they should be included in access tokens, in identity tokens or in both. + + switch (claim.Type) + { + case OpenIddictConstants.Claims.Name: + yield return OpenIddictConstants.Destinations.AccessToken; + + if (principal.HasScope(OpenIddictConstants.Permissions.Scopes.Profile)) + yield return OpenIddictConstants.Destinations.IdentityToken; + + yield break; + + case OpenIddictConstants.Claims.Email: + yield return OpenIddictConstants.Destinations.AccessToken; + + if (principal.HasScope(OpenIddictConstants.Permissions.Scopes.Email)) + yield return OpenIddictConstants.Destinations.IdentityToken; + + yield break; + + case OpenIddictConstants.Claims.Role: + yield return OpenIddictConstants.Destinations.AccessToken; + + if (principal.HasScope(OpenIddictConstants.Permissions.Scopes.Roles)) + yield return OpenIddictConstants.Destinations.IdentityToken; + + yield break; + + // Never include the security stamp in the access and identity tokens, as it's a secret value. + case "AspNet.Identity.SecurityStamp": yield break; + + default: + yield return OpenIddictConstants.Destinations.AccessToken; + yield break; + } + } +} diff --git a/src/Fengling.AuthService/Controllers/DashboardController.cs b/src/Fengling.AuthService/Controllers/DashboardController.cs new file mode 100644 index 0000000..1732d36 --- /dev/null +++ b/src/Fengling.AuthService/Controllers/DashboardController.cs @@ -0,0 +1,61 @@ +using Fengling.AuthService.ViewModels; +using Microsoft.AspNetCore.Mvc; + +namespace Fengling.AuthService.Controllers; + +[Route("dashboard")] +public class DashboardController : Controller +{ + private readonly ILogger _logger; + + public DashboardController(ILogger logger) + { + _logger = logger; + } + + [HttpGet("")] + [HttpGet("index")] + public IActionResult Index() + { + if (User.Identity?.IsAuthenticated != true) + { + return RedirectToAction("Login", "Account", new { returnUrl = "/dashboard" }); + } + + return View("Index", new DashboardViewModel + { + Username = User.Identity?.Name, + Email = User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value + }); + } + + [HttpGet("profile")] + public IActionResult Profile() + { + if (User.Identity?.IsAuthenticated != true) + { + return RedirectToAction("Login", "Account", new { returnUrl = "/dashboard/profile" }); + } + + return View(new DashboardViewModel + { + Username = User.Identity?.Name, + Email = User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value + }); + } + + [HttpGet("settings")] + public IActionResult Settings() + { + if (User.Identity?.IsAuthenticated != true) + { + return RedirectToAction("Login", "Account", new { returnUrl = "/dashboard/settings" }); + } + + return View(new DashboardViewModel + { + Username = User.Identity?.Name, + Email = User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value + }); + } +} diff --git a/src/Fengling.AuthService/Controllers/LogoutController.cs b/src/Fengling.AuthService/Controllers/LogoutController.cs new file mode 100644 index 0000000..502a4b6 --- /dev/null +++ b/src/Fengling.AuthService/Controllers/LogoutController.cs @@ -0,0 +1,71 @@ +using Fengling.AuthService.Data; +using Fengling.AuthService.Models; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Fengling.AuthService.Controllers; + +[ApiController] +[Route("connect")] +public class LogoutController : ControllerBase +{ + private readonly IOpenIddictApplicationManager _applicationManager; + private readonly IOpenIddictAuthorizationManager _authorizationManager; + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly ILogger _logger; + + public LogoutController( + IOpenIddictApplicationManager applicationManager, + IOpenIddictAuthorizationManager authorizationManager, + UserManager userManager, + SignInManager signInManager, + ILogger logger) + { + _applicationManager = applicationManager; + _authorizationManager = authorizationManager; + _userManager = userManager; + _signInManager = signInManager; + _logger = logger; + } + + [HttpGet("endsession")] + [HttpPost("endsession")] + [IgnoreAntiforgeryToken] + public async Task EndSession() + { + var request = HttpContext.GetOpenIddictServerRequest() ?? + throw new InvalidOperationException("OpenIddict request is null"); + + var result = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme); + if (result.Succeeded) + { + await _signInManager.SignOutAsync(); + } + + if (request.ClientId != null) + { + var application = await _applicationManager.FindByClientIdAsync(request.ClientId); + if (application != null) + { + var postLogoutRedirectUri = await _applicationManager.GetPostLogoutRedirectUrisAsync(application); + if (!string.IsNullOrEmpty(request.PostLogoutRedirectUri)) + { + if (postLogoutRedirectUri.Contains(request.PostLogoutRedirectUri)) + { + return Redirect(request.PostLogoutRedirectUri); + } + } + } + } + + + return Redirect("/"); + } +} diff --git a/src/Fengling.AuthService/Controllers/OAuthClientsController.cs b/src/Fengling.AuthService/Controllers/OAuthClientsController.cs index b0c3138..a98e39d 100644 --- a/src/Fengling.AuthService/Controllers/OAuthClientsController.cs +++ b/src/Fengling.AuthService/Controllers/OAuthClientsController.cs @@ -1,12 +1,15 @@ using Fengling.AuthService.Data; using Fengling.AuthService.Models; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System.Security.Claims; namespace Fengling.AuthService.Controllers; [ApiController] [Route("api/[controller]")] +[Authorize] public class OAuthClientsController : ControllerBase { private readonly ApplicationDbContext _context; @@ -21,9 +24,60 @@ public class OAuthClientsController : ControllerBase } [HttpGet] - public async Task>> GetClients() + public async Task> GetClients( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 10, + [FromQuery] string? displayName = null, + [FromQuery] string? clientId = null, + [FromQuery] string? status = null) { - return await _context.OAuthApplications.ToListAsync(); + var query = _context.OAuthApplications.AsQueryable(); + + if (!string.IsNullOrEmpty(displayName)) + { + query = query.Where(c => c.DisplayName.Contains(displayName)); + } + + if (!string.IsNullOrEmpty(clientId)) + { + query = query.Where(c => c.ClientId.Contains(clientId)); + } + + if (!string.IsNullOrEmpty(status)) + { + query = query.Where(c => c.Status == status); + } + + var totalCount = await query.CountAsync(); + var clients = await query + .OrderByDescending(c => c.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + var result = clients.Select(c => new + { + id = c.Id, + clientId = c.ClientId, + displayName = c.DisplayName, + redirectUris = c.RedirectUris, + postLogoutRedirectUris = c.PostLogoutRedirectUris, + scopes = c.Scopes, + grantTypes = c.GrantTypes, + clientType = c.ClientType, + consentType = c.ConsentType, + status = c.Status, + description = c.Description, + createdAt = c.CreatedAt, + }); + + return Ok(new + { + items = result, + totalCount, + page, + pageSize + }); } [HttpGet("{id}")] @@ -34,27 +88,97 @@ public class OAuthClientsController : ControllerBase { return NotFound(); } - return client; + + return Ok(new + { + id = client.Id, + clientId = client.ClientId, + displayName = client.DisplayName, + redirectUris = client.RedirectUris, + postLogoutRedirectUris = client.PostLogoutRedirectUris, + scopes = client.Scopes, + grantTypes = client.GrantTypes, + clientType = client.ClientType, + consentType = client.ConsentType, + status = client.Status, + description = client.Description, + createdAt = client.CreatedAt, + }); + } + + [HttpGet("{id}/secret")] + public async Task> GetClientSecret(long id) + { + var client = await _context.OAuthApplications.FindAsync(id); + if (client == null) + { + return NotFound(); + } + + return Ok(new + { + clientId = client.ClientId, + clientSecret = client.ClientSecret, + }); } [HttpPost] - public async Task> CreateClient(OAuthApplication application) + public async Task> CreateClient(CreateOAuthClientDto dto) { - _context.OAuthApplications.Add(application); + if (await _context.OAuthApplications.AnyAsync(c => c.ClientId == dto.ClientId)) + { + return BadRequest(new { message = "Client ID 已存在" }); + } + + var client = new OAuthApplication + { + ClientId = dto.ClientId, + ClientSecret = dto.ClientSecret, + DisplayName = dto.DisplayName, + RedirectUris = dto.RedirectUris, + PostLogoutRedirectUris = dto.PostLogoutRedirectUris, + Scopes = dto.Scopes, + GrantTypes = dto.GrantTypes, + ClientType = dto.ClientType, + ConsentType = dto.ConsentType, + Status = dto.Status, + Description = dto.Description, + CreatedAt = DateTime.UtcNow, + }; + + _context.OAuthApplications.Add(client); await _context.SaveChangesAsync(); - return CreatedAtAction(nameof(GetClient), new { id = application.Id }, application); + + await CreateAuditLog("oauth", "create", "OAuthClient", client.Id, client.DisplayName, null, SerializeToJson(dto)); + + return CreatedAtAction(nameof(GetClient), new { id = client.Id }, client); } [HttpPut("{id}")] - public async Task UpdateClient(long id, OAuthApplication application) + public async Task UpdateClient(long id, UpdateOAuthClientDto dto) { - if (id != application.Id) + var client = await _context.OAuthApplications.FindAsync(id); + if (client == null) { - return BadRequest(); + return NotFound(); } - _context.Entry(application).State = EntityState.Modified; + var oldValue = SerializeToJson(client); + + client.DisplayName = dto.DisplayName; + client.RedirectUris = dto.RedirectUris; + client.PostLogoutRedirectUris = dto.PostLogoutRedirectUris; + client.Scopes = dto.Scopes; + client.GrantTypes = dto.GrantTypes; + client.ClientType = dto.ClientType; + client.ConsentType = dto.ConsentType; + client.Status = dto.Status; + client.Description = dto.Description; + await _context.SaveChangesAsync(); + + await CreateAuditLog("oauth", "update", "OAuthClient", client.Id, client.DisplayName, oldValue, SerializeToJson(client)); + return NoContent(); } @@ -67,8 +191,73 @@ public class OAuthClientsController : ControllerBase return NotFound(); } + var oldValue = SerializeToJson(client); + _context.OAuthApplications.Remove(client); await _context.SaveChangesAsync(); + + await CreateAuditLog("oauth", "delete", "OAuthClient", client.Id, client.DisplayName, oldValue); + return NoContent(); } + + private async Task CreateAuditLog(string operation, string action, string targetType, long? targetId, string? targetName, string? oldValue = null, string? newValue = null) + { + var userName = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.Identity?.Name ?? "system"; + var tenantId = User.FindFirstValue("TenantId"); + + var log = new AuditLog + { + Operator = userName, + TenantId = tenantId, + Operation = operation, + Action = action, + TargetType = targetType, + TargetId = targetId, + TargetName = targetName, + IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + Status = "success", + OldValue = oldValue, + NewValue = newValue, + }; + + _context.AuditLogs.Add(log); + await _context.SaveChangesAsync(); + } + + private string SerializeToJson(object obj) + { + return System.Text.Json.JsonSerializer.Serialize(obj, new System.Text.Json.JsonSerializerOptions + { + WriteIndented = false + }); + } +} + +public class CreateOAuthClientDto +{ + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string[] RedirectUris { get; set; } = Array.Empty(); + public string[] PostLogoutRedirectUris { get; set; } = Array.Empty(); + public string[] Scopes { get; set; } = Array.Empty(); + public string[] GrantTypes { get; set; } = Array.Empty(); + public string ClientType { get; set; } = "confidential"; + public string ConsentType { get; set; } = "implicit"; + public string Status { get; set; } = "active"; + public string? Description { get; set; } +} + +public class UpdateOAuthClientDto +{ + public string DisplayName { get; set; } = string.Empty; + public string[] RedirectUris { get; set; } = Array.Empty(); + public string[] PostLogoutRedirectUris { get; set; } = Array.Empty(); + public string[] Scopes { get; set; } = Array.Empty(); + public string[] GrantTypes { get; set; } = Array.Empty(); + public string ClientType { get; set; } = "confidential"; + public string ConsentType { get; set; } = "implicit"; + public string Status { get; set; } = "active"; + public string? Description { get; set; } } diff --git a/src/Fengling.AuthService/Controllers/RolesController.cs b/src/Fengling.AuthService/Controllers/RolesController.cs new file mode 100644 index 0000000..9beef92 --- /dev/null +++ b/src/Fengling.AuthService/Controllers/RolesController.cs @@ -0,0 +1,290 @@ +using Fengling.AuthService.Data; +using Fengling.AuthService.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; + +namespace Fengling.AuthService.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class RolesController : ControllerBase +{ + private readonly ApplicationDbContext _context; + private readonly RoleManager _roleManager; + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public RolesController( + ApplicationDbContext context, + RoleManager roleManager, + UserManager userManager, + ILogger logger) + { + _context = context; + _roleManager = roleManager; + _userManager = userManager; + _logger = logger; + } + + [HttpGet] + public async Task> GetRoles( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 10, + [FromQuery] string? name = null, + [FromQuery] string? tenantId = null) + { + var query = _context.Roles.AsQueryable(); + + if (!string.IsNullOrEmpty(name)) + { + query = query.Where(r => r.Name != null && r.Name.Contains(name)); + } + + if (!string.IsNullOrEmpty(tenantId)) + { + query = query.Where(r => r.TenantId.ToString() == tenantId); + } + + var totalCount = await query.CountAsync(); + var roles = await query + .OrderByDescending(r => r.CreatedTime) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + var result = new List(); + + foreach (var role in roles) + { + var users = await _userManager.GetUsersInRoleAsync(role.Name!); + result.Add(new + { + id = role.Id, + name = role.Name, + displayName = role.DisplayName, + description = role.Description, + tenantId = role.TenantId, + isSystem = role.IsSystem, + permissions = role.Permissions, + userCount = users.Count, + createdAt = role.CreatedTime, + }); + } + + return Ok(new + { + items = result, + totalCount, + page, + pageSize + }); + } + + [HttpGet("{id}")] + public async Task> GetRole(long id) + { + var role = await _context.Roles.FindAsync(id); + if (role == null) + { + return NotFound(); + } + + return Ok(new + { + id = role.Id, + name = role.Name, + displayName = role.DisplayName, + description = role.Description, + tenantId = role.TenantId, + isSystem = role.IsSystem, + permissions = role.Permissions, + createdAt = role.CreatedTime, + }); + } + + [HttpGet("{id}/users")] + public async Task>> GetRoleUsers(long id) + { + var role = await _context.Roles.FindAsync(id); + if (role == null) + { + return NotFound(); + } + + var users = await _userManager.GetUsersInRoleAsync(role.Name!); + + var result = users.Select(async u => new + { + id = u.Id, + userName = u.UserName, + email = u.Email, + realName = u.RealName, + tenantId = u.TenantId, + roles = await _userManager.GetRolesAsync(u), + isActive = !u.LockoutEnabled || u.LockoutEnd == null || u.LockoutEnd < DateTimeOffset.UtcNow, + createdAt = u.CreatedTime, + }); + + return Ok(await Task.WhenAll(result)); + } + + [HttpPost] + public async Task> CreateRole(CreateRoleDto dto) + { + var role = new ApplicationRole + { + Name = dto.Name, + DisplayName = dto.DisplayName, + Description = dto.Description, + TenantId = dto.TenantId, + Permissions = dto.Permissions, + IsSystem = false, + CreatedTime = DateTime.UtcNow, + }; + + var result = await _roleManager.CreateAsync(role); + if (!result.Succeeded) + { + return BadRequest(result.Errors); + } + + await CreateAuditLog("role", "create", "Role", role.Id, role.DisplayName, null, SerializeToJson(dto)); + + return CreatedAtAction(nameof(GetRole), new { id = role.Id }, role); + } + + [HttpPut("{id}")] + public async Task UpdateRole(long id, UpdateRoleDto dto) + { + var role = await _context.Roles.FindAsync(id); + if (role == null) + { + return NotFound(); + } + + if (role.IsSystem) + { + return BadRequest("系统角色不能修改"); + } + + var oldValue = System.Text.Json.JsonSerializer.Serialize(role); + + role.DisplayName = dto.DisplayName; + role.Description = dto.Description; + role.Permissions = dto.Permissions; + + await _context.SaveChangesAsync(); + + await CreateAuditLog("role", "update", "Role", role.Id, role.DisplayName, oldValue, System.Text.Json.JsonSerializer.Serialize(role)); + + return NoContent(); + } + + [HttpDelete("{id}")] + public async Task DeleteRole(long id) + { + var role = await _context.Roles.FindAsync(id); + if (role == null) + { + return NotFound(); + } + + if (role.IsSystem) + { + return BadRequest("系统角色不能删除"); + } + + var oldValue = System.Text.Json.JsonSerializer.Serialize(role); + var users = await _userManager.GetUsersInRoleAsync(role.Name!); + + foreach (var user in users) + { + await _userManager.RemoveFromRoleAsync(user, role.Name!); + } + + _context.Roles.Remove(role); + await _context.SaveChangesAsync(); + + await CreateAuditLog("role", "delete", "Role", role.Id, role.DisplayName, oldValue); + + return NoContent(); + } + + [HttpDelete("{id}/users/{userId}")] + public async Task RemoveUserFromRole(long id, long userId) + { + var role = await _context.Roles.FindAsync(id); + if (role == null) + { + return NotFound(); + } + + var user = await _userManager.FindByIdAsync(userId.ToString()); + if (user == null) + { + return NotFound(); + } + + var result = await _userManager.RemoveFromRoleAsync(user, role.Name!); + if (!result.Succeeded) + { + return BadRequest(result.Errors); + } + + await CreateAuditLog("role", "update", "UserRole", null, $"{role.Name} - {user.UserName}"); + + return NoContent(); + } + + private async Task CreateAuditLog(string operation, string action, string targetType, long? targetId, string? targetName, string? oldValue = null, string? newValue = null) + { + var userName = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.Identity?.Name ?? "system"; + var tenantId = User.FindFirstValue("TenantId"); + + var log = new AuditLog + { + Operator = userName, + TenantId = tenantId, + Operation = operation, + Action = action, + TargetType = targetType, + TargetId = targetId, + TargetName = targetName, + IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + Status = "success", + OldValue = oldValue, + NewValue = newValue, + }; + + _context.AuditLogs.Add(log); + await _context.SaveChangesAsync(); + } + + private string SerializeToJson(object obj) + { + return System.Text.Json.JsonSerializer.Serialize(obj, new System.Text.Json.JsonSerializerOptions + { + WriteIndented = false + }); + } +} + +public class CreateRoleDto +{ + public string Name { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string? Description { get; set; } + public long? TenantId { get; set; } + public List Permissions { get; set; } = new(); +} + +public class UpdateRoleDto +{ + public string DisplayName { get; set; } = string.Empty; + public string? Description { get; set; } + public List Permissions { get; set; } = new(); +} diff --git a/src/Fengling.AuthService/Controllers/StatsController.cs b/src/Fengling.AuthService/Controllers/StatsController.cs new file mode 100644 index 0000000..c665ee3 --- /dev/null +++ b/src/Fengling.AuthService/Controllers/StatsController.cs @@ -0,0 +1,62 @@ +using Fengling.AuthService.Data; +using Fengling.AuthService.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Fengling.AuthService.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class StatsController : ControllerBase +{ + private readonly ApplicationDbContext _context; + private readonly ILogger _logger; + + public StatsController( + ApplicationDbContext context, + ILogger logger) + { + _context = context; + _logger = logger; + } + + [HttpGet("dashboard")] + public async Task> GetDashboardStats() + { + var today = DateTime.UtcNow.Date; + var tomorrow = today.AddDays(1); + + var userCount = await _context.Users.CountAsync(u => !u.IsDeleted); + var tenantCount = await _context.Tenants.CountAsync(t => !t.IsDeleted); + var oauthClientCount = await _context.OAuthApplications.CountAsync(); + var todayAccessCount = await _context.AccessLogs + .CountAsync(l => l.CreatedAt >= today && l.CreatedAt < tomorrow); + + return Ok(new + { + userCount, + tenantCount, + oauthClientCount, + todayAccessCount, + }); + } + + [HttpGet("system")] + public ActionResult GetSystemStats() + { + var uptime = TimeSpan.FromMilliseconds(Environment.TickCount64); + var process = System.Diagnostics.Process.GetCurrentProcess(); + + return Ok(new + { + uptime = $"{uptime.Days}天 {uptime.Hours}小时 {uptime.Minutes}分钟", + memoryUsed = process.WorkingSet64 / 1024 / 1024, + cpuTime = process.TotalProcessorTime, + machineName = Environment.MachineName, + osVersion = Environment.OSVersion.ToString(), + processorCount = Environment.ProcessorCount, + }); + } +} diff --git a/src/Fengling.AuthService/Controllers/TenantsController.cs b/src/Fengling.AuthService/Controllers/TenantsController.cs new file mode 100644 index 0000000..bb2ffc8 --- /dev/null +++ b/src/Fengling.AuthService/Controllers/TenantsController.cs @@ -0,0 +1,344 @@ +using Fengling.AuthService.Data; +using Fengling.AuthService.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using System.Text.Json; + +namespace Fengling.AuthService.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class TenantsController : ControllerBase +{ + private readonly ApplicationDbContext _context; + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public TenantsController( + ApplicationDbContext context, + UserManager userManager, + ILogger logger) + { + _context = context; + _userManager = userManager; + _logger = logger; + } + + [HttpGet] + public async Task> GetTenants( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 10, + [FromQuery] string? name = null, + [FromQuery] string? tenantId = null, + [FromQuery] string? status = null) + { + var query = _context.Tenants.AsQueryable(); + + if (!string.IsNullOrEmpty(name)) + { + query = query.Where(t => t.Name.Contains(name)); + } + + if (!string.IsNullOrEmpty(tenantId)) + { + query = query.Where(t => t.TenantId.Contains(tenantId)); + } + + if (!string.IsNullOrEmpty(status)) + { + query = query.Where(t => t.Status == status); + } + + var totalCount = await query.CountAsync(); + var tenants = await query + .OrderByDescending(t => t.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + var result = new List(); + + foreach (var tenant in tenants) + { + var userCount = await _context.Users.CountAsync(u => u.TenantId == tenant.Id && !u.IsDeleted); + result.Add(new + { + id = tenant.Id, + tenantId = tenant.TenantId, + name = tenant.Name, + contactName = tenant.ContactName, + contactEmail = tenant.ContactEmail, + contactPhone = tenant.ContactPhone, + maxUsers = tenant.MaxUsers, + userCount, + status = tenant.Status, + expiresAt = tenant.ExpiresAt, + description = tenant.Description, + createdAt = tenant.CreatedAt, + }); + } + + return Ok(new + { + items = result, + totalCount, + page, + pageSize + }); + } + + [HttpGet("{id}")] + public async Task> GetTenant(long id) + { + var tenant = await _context.Tenants.FindAsync(id); + if (tenant == null) + { + return NotFound(); + } + + return Ok(new + { + id = tenant.Id, + tenantId = tenant.TenantId, + name = tenant.Name, + contactName = tenant.ContactName, + contactEmail = tenant.ContactEmail, + contactPhone = tenant.ContactPhone, + maxUsers = tenant.MaxUsers, + status = tenant.Status, + expiresAt = tenant.ExpiresAt, + description = tenant.Description, + createdAt = tenant.CreatedAt, + updatedAt = tenant.UpdatedAt, + }); + } + + [HttpGet("{tenantId}/users")] + public async Task>> GetTenantUsers(string tenantId) + { + var tenant = await _context.Tenants.FirstOrDefaultAsync(t => t.TenantId == tenantId); + if (tenant == null) + { + return NotFound(); + } + + var users = await _context.Users + .Where(u => u.TenantId == tenant.Id && !u.IsDeleted) + .ToListAsync(); + + var result = users.Select(async u => new + { + id = u.Id, + userName = u.UserName, + email = u.Email, + realName = u.RealName, + tenantId = u.TenantId, + roles = await _userManager.GetRolesAsync(u), + isActive = !u.LockoutEnabled || u.LockoutEnd == null || u.LockoutEnd < DateTimeOffset.UtcNow, + createdAt = u.CreatedTime, + }); + + return Ok(await Task.WhenAll(result)); + } + + [HttpGet("{tenantId}/roles")] + public async Task>> GetTenantRoles(string tenantId) + { + var tenant = await _context.Tenants.FirstOrDefaultAsync(t => t.TenantId == tenantId); + if (tenant == null) + { + return NotFound(); + } + + var roles = await _context.Roles + .Where(r => r.TenantId == tenant.Id) + .ToListAsync(); + + var result = roles.Select(r => new + { + id = r.Id, + name = r.Name, + displayName = r.DisplayName, + }); + + return Ok(result); + } + + [HttpGet("{tenantId}/settings")] + public async Task> GetTenantSettings(string tenantId) + { + var tenant = await _context.Tenants.FirstOrDefaultAsync(t => t.TenantId == tenantId); + if (tenant == null) + { + return NotFound(); + } + + var settings = new TenantSettings + { + AllowRegistration = false, + AllowedEmailDomains = "", + DefaultRoleId = null, + PasswordPolicy = new List { "requireNumber", "requireLowercase" }, + MinPasswordLength = 8, + SessionTimeout = 120, + }; + + return Ok(settings); + } + + [HttpPut("{tenantId}/settings")] + public async Task UpdateTenantSettings(string tenantId, TenantSettings settings) + { + var tenant = await _context.Tenants.FirstOrDefaultAsync(t => t.TenantId == tenantId); + if (tenant == null) + { + return NotFound(); + } + + await CreateAuditLog("tenant", "update", "TenantSettings", tenant.Id, tenant.TenantId, null, JsonSerializer.Serialize(settings)); + + return NoContent(); + } + + [HttpPost] + public async Task> CreateTenant(CreateTenantDto dto) + { + var tenant = new Tenant + { + TenantId = dto.TenantId, + Name = dto.Name, + ContactName = dto.ContactName, + ContactEmail = dto.ContactEmail, + ContactPhone = dto.ContactPhone, + MaxUsers = dto.MaxUsers, + Description = dto.Description, + Status = dto.Status, + ExpiresAt = dto.ExpiresAt, + CreatedAt = DateTime.UtcNow, + }; + + _context.Tenants.Add(tenant); + await _context.SaveChangesAsync(); + + await CreateAuditLog("tenant", "create", "Tenant", tenant.Id, tenant.TenantId, null, JsonSerializer.Serialize(dto)); + + return CreatedAtAction(nameof(GetTenant), new { id = tenant.Id }, tenant); + } + + [HttpPut("{id}")] + public async Task UpdateTenant(long id, UpdateTenantDto dto) + { + var tenant = await _context.Tenants.FindAsync(id); + if (tenant == null) + { + return NotFound(); + } + + var oldValue = JsonSerializer.Serialize(tenant); + + tenant.Name = dto.Name; + tenant.ContactName = dto.ContactName; + tenant.ContactEmail = dto.ContactEmail; + tenant.ContactPhone = dto.ContactPhone; + tenant.MaxUsers = dto.MaxUsers; + tenant.Description = dto.Description; + tenant.Status = dto.Status; + tenant.ExpiresAt = dto.ExpiresAt; + tenant.UpdatedAt = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + + await CreateAuditLog("tenant", "update", "Tenant", tenant.Id, tenant.TenantId, oldValue, JsonSerializer.Serialize(tenant)); + + return NoContent(); + } + + [HttpDelete("{id}")] + public async Task DeleteTenant(long id) + { + var tenant = await _context.Tenants.FindAsync(id); + if (tenant == null) + { + return NotFound(); + } + + var oldValue = JsonSerializer.Serialize(tenant); + + var users = await _context.Users.Where(u => u.TenantId == tenant.Id).ToListAsync(); + foreach (var user in users) + { + user.IsDeleted = true; + user.UpdatedTime = DateTime.UtcNow; + } + + tenant.IsDeleted = true; + await _context.SaveChangesAsync(); + + await CreateAuditLog("tenant", "delete", "Tenant", tenant.Id, tenant.TenantId, oldValue); + + return NoContent(); + } + + private async Task CreateAuditLog(string operation, string action, string targetType, long? targetId, string? targetName, string? oldValue = null, string? newValue = null) + { + var userName = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.Identity?.Name ?? "system"; + var tenantId = User.FindFirstValue("TenantId"); + + var log = new AuditLog + { + Operator = userName, + TenantId = tenantId, + Operation = operation, + Action = action, + TargetType = targetType, + TargetId = targetId, + TargetName = targetName, + IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + Status = "success", + OldValue = oldValue, + NewValue = newValue, + }; + + _context.AuditLogs.Add(log); + await _context.SaveChangesAsync(); + } +} + +public class CreateTenantDto +{ + public string TenantId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string ContactName { get; set; } = string.Empty; + public string ContactEmail { get; set; } = string.Empty; + public string? ContactPhone { get; set; } + public int? MaxUsers { get; set; } + public string? Description { get; set; } + public string Status { get; set; } = "active"; + public DateTime? ExpiresAt { get; set; } +} + +public class UpdateTenantDto +{ + public string Name { get; set; } = string.Empty; + public string ContactName { get; set; } = string.Empty; + public string ContactEmail { get; set; } = string.Empty; + public string? ContactPhone { get; set; } + public int? MaxUsers { get; set; } + public string? Description { get; set; } + public string Status { get; set; } = "active"; + public DateTime? ExpiresAt { get; set; } +} + +public class TenantSettings +{ + public bool AllowRegistration { get; set; } + public string AllowedEmailDomains { get; set; } = string.Empty; + public long? DefaultRoleId { get; set; } + public List PasswordPolicy { get; set; } = new(); + public int MinPasswordLength { get; set; } = 8; + public int SessionTimeout { get; set; } = 120; +} diff --git a/src/Fengling.AuthService/Controllers/TokenController.cs b/src/Fengling.AuthService/Controllers/TokenController.cs new file mode 100644 index 0000000..1949792 --- /dev/null +++ b/src/Fengling.AuthService/Controllers/TokenController.cs @@ -0,0 +1,255 @@ +using Fengling.AuthService.Data; +using Fengling.AuthService.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using System.Security.Claims; +using System.Security.Cryptography; +using Microsoft.AspNetCore; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Fengling.AuthService.Controllers; + +[ApiController] +[Route("connect")] +public class TokenController( + IOpenIddictApplicationManager applicationManager, + IOpenIddictAuthorizationManager authorizationManager, + IOpenIddictScopeManager scopeManager, + UserManager userManager, + SignInManager signInManager, + ILogger logger) + : ControllerBase +{ + private readonly ILogger _logger = logger; + + [HttpPost("token")] + public async Task Exchange() + { + var request = HttpContext.GetOpenIddictServerRequest() ?? + throw new InvalidOperationException("OpenIddict request is null"); + var result = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme); + + if (request.IsAuthorizationCodeGrantType()) + { + return await ExchangeAuthorizationCodeAsync(request, result); + } + + if (request.IsRefreshTokenGrantType()) + { + return await ExchangeRefreshTokenAsync(request); + } + + if (request.IsPasswordGrantType()) + { + return await ExchangePasswordAsync(request); + } + + return BadRequest(new OpenIddictResponse + { + Error = Errors.UnsupportedGrantType, + ErrorDescription = "The specified grant type is not supported." + }); + } + + private async Task ExchangeAuthorizationCodeAsync(OpenIddictRequest request, + AuthenticateResult result) + { + var application = await applicationManager.FindByClientIdAsync(request.ClientId); + if (application == null) + { + return BadRequest(new OpenIddictResponse + { + Error = Errors.InvalidClient, + ErrorDescription = "The specified client is invalid." + }); + } + + var authorization = await authorizationManager.FindAsync( + subject: result.Principal?.GetClaim(Claims.Subject), + client: await applicationManager.GetIdAsync(application), + status: Statuses.Valid, + type: AuthorizationTypes.Permanent, + scopes: request.GetScopes()).FirstOrDefaultAsync(); + + if (authorization == null) + { + return BadRequest(new OpenIddictResponse + { + Error = Errors.InvalidGrant, + ErrorDescription = "The authorization code is invalid." + }); + } + + var user = await userManager.FindByIdAsync(result.Principal?.GetClaim(Claims.Subject)); + if (user == null || user.IsDeleted) + { + return BadRequest(new OpenIddictResponse + { + Error = Errors.InvalidGrant, + ErrorDescription = "The user is no longer valid." + }); + } + + var claims = new List + { + new(Claims.Subject, await userManager.GetUserIdAsync(user)), + new(Claims.Name, await userManager.GetUserNameAsync(user)), + new(Claims.Email, await userManager.GetEmailAsync(user) ?? ""), + new("tenant_id", user.TenantId.ToString()) + }; + + var roles = await userManager.GetRolesAsync(user); + foreach (var role in roles) + { + claims.Add(new Claim(Claims.Role, role)); + } + + var identity = new ClaimsIdentity(claims, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + var principal = new ClaimsPrincipal(identity); + + principal.SetScopes(request.GetScopes()); + principal.SetResources(await scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync()); + principal.SetAuthorizationId(await authorizationManager.GetIdAsync(authorization)); + + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + private async Task ExchangeRefreshTokenAsync(OpenIddictRequest request) + { + var principalResult = + await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + if (principalResult is not { Succeeded: true }) + { + return BadRequest(new OpenIddictResponse + { + Error = Errors.InvalidGrant, + ErrorDescription = "The refresh token is invalid." + }); + } + + var user = await userManager.GetUserAsync(principalResult.Principal); + if (user == null || user.IsDeleted) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The token is no longer valid." + }!)); + } + + // Ensure the user is still allowed to sign in. + if (!await signInManager.CanSignInAsync(user)) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The user is no longer allowed to sign in." + }!)); + } + + var principal = principalResult.Principal; + foreach (var claim in principal!.Claims) + { + claim.SetDestinations(GetDestinations(claim, principal)); + } + + + principal.SetScopes(request.GetScopes()); + principal.SetResources(await scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync()); + + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + private IEnumerable GetDestinations(Claim claim, ClaimsPrincipal principal) + { + // Note: by default, claims are NOT automatically included in the access and identity tokens. + // To allow OpenIddict to serialize them, you must attach them a destination, that specifies + // whether they should be included in access tokens, in identity tokens or in both. + + switch (claim.Type) + { + case OpenIddictConstants.Claims.Name: + yield return OpenIddictConstants.Destinations.AccessToken; + + if (principal.HasScope(OpenIddictConstants.Permissions.Scopes.Profile)) + yield return OpenIddictConstants.Destinations.IdentityToken; + + yield break; + + case OpenIddictConstants.Claims.Email: + yield return OpenIddictConstants.Destinations.AccessToken; + + if (principal.HasScope(OpenIddictConstants.Permissions.Scopes.Email)) + yield return OpenIddictConstants.Destinations.IdentityToken; + + yield break; + + case OpenIddictConstants.Claims.Role: + yield return OpenIddictConstants.Destinations.AccessToken; + + if (principal.HasScope(OpenIddictConstants.Permissions.Scopes.Roles)) + yield return OpenIddictConstants.Destinations.IdentityToken; + + yield break; + + // Never include the security stamp in the access and identity tokens, as it's a secret value. + case "AspNet.Identity.SecurityStamp": yield break; + + default: + yield return OpenIddictConstants.Destinations.AccessToken; + yield break; + } + } + + private async Task ExchangePasswordAsync(OpenIddictRequest request) + { + var user = await userManager.FindByNameAsync(request.Username); + if (user == null || user.IsDeleted) + { + return BadRequest(new OpenIddictResponse + { + Error = Errors.InvalidGrant, + ErrorDescription = "用户名或密码错误" + }); + } + + var signInResult = await signInManager.CheckPasswordSignInAsync(user, request.Password, false); + if (!signInResult.Succeeded) + { + return BadRequest(new OpenIddictResponse + { + Error = Errors.InvalidGrant, + ErrorDescription = "用户名或密码错误" + }); + } + + var claims = new List + { + new(Claims.Subject, await userManager.GetUserIdAsync(user)), + new(Claims.Name, await userManager.GetUserNameAsync(user)), + new(Claims.Email, await userManager.GetEmailAsync(user) ?? ""), + new("tenant_id", user.TenantId.ToString()) + }; + + var roles = await userManager.GetRolesAsync(user); + foreach (var role in roles) + { + claims.Add(new Claim(Claims.Role, role)); + } + + var identity = new ClaimsIdentity(claims, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + var principal = new ClaimsPrincipal(identity); + + principal.SetScopes(request.GetScopes()); + principal.SetResources(await scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync()); + + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } +} \ No newline at end of file diff --git a/src/Fengling.AuthService/Controllers/UsersController.cs b/src/Fengling.AuthService/Controllers/UsersController.cs new file mode 100644 index 0000000..5c098c3 --- /dev/null +++ b/src/Fengling.AuthService/Controllers/UsersController.cs @@ -0,0 +1,291 @@ +using Fengling.AuthService.Data; +using Fengling.AuthService.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; + +namespace Fengling.AuthService.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class UsersController : ControllerBase +{ + private readonly ApplicationDbContext _context; + private readonly UserManager _userManager; + private readonly RoleManager _roleManager; + private readonly ILogger _logger; + + public UsersController( + ApplicationDbContext context, + UserManager userManager, + RoleManager roleManager, + ILogger logger) + { + _context = context; + _userManager = userManager; + _roleManager = roleManager; + _logger = logger; + } + + [HttpGet] + public async Task> GetUsers( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 10, + [FromQuery] string? userName = null, + [FromQuery] string? email = null, + [FromQuery] string? tenantId = null) + { + var query = _context.Users.AsQueryable(); + + if (!string.IsNullOrEmpty(userName)) + { + query = query.Where(u => u.UserName!.Contains(userName)); + } + + if (!string.IsNullOrEmpty(email)) + { + query = query.Where(u => u.Email != null && u.Email.Contains(email)); + } + + if (!string.IsNullOrEmpty(tenantId)) + { + query = query.Where(u => u.TenantId.ToString() == tenantId); + } + + var totalCount = await query.CountAsync(); + var users = await query + .OrderByDescending(u => u.CreatedTime) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + var result = users.Select(async u => new + { + id = u.Id, + userName = u.UserName, + email = u.Email, + realName = u.RealName, + phone = u.Phone, + tenantId = u.TenantId, + roles = (await _userManager.GetRolesAsync(u)).ToList(), + emailConfirmed = u.EmailConfirmed, + isActive = !u.LockoutEnabled || u.LockoutEnd == null || u.LockoutEnd < DateTimeOffset.UtcNow, + createdAt = u.CreatedTime, + }); + + return Ok(new + { + items = await Task.WhenAll(result), + totalCount, + page, + pageSize + }); + } + + [HttpGet("{id}")] + public async Task> GetUser(long id) + { + var user = await _context.Users.FindAsync(id); + if (user == null) + { + return NotFound(); + } + + var roles = await _userManager.GetRolesAsync(user); + + return Ok(new + { + id = user.Id, + userName = user.UserName, + email = user.Email, + realName = user.RealName, + phone = user.Phone, + tenantId = user.TenantId, + roles, + emailConfirmed = user.EmailConfirmed, + isActive = !user.LockoutEnabled || user.LockoutEnd == null || user.LockoutEnd < DateTimeOffset.UtcNow, + createdAt = user.CreatedTime, + }); + } + + [HttpPost] + public async Task> CreateUser(CreateUserDto dto) + { + var user = new ApplicationUser + { + UserName = dto.UserName, + Email = dto.Email, + RealName = dto.RealName, + Phone = dto.Phone, + TenantId = dto.TenantId ?? 0, + EmailConfirmed = dto.EmailConfirmed, + CreatedTime = DateTime.UtcNow, + }; + + var result = await _userManager.CreateAsync(user, dto.Password); + if (!result.Succeeded) + { + return BadRequest(result.Errors); + } + + if (dto.RoleIds != null && dto.RoleIds.Any()) + { + foreach (var roleId in dto.RoleIds) + { + var role = await _roleManager.FindByIdAsync(roleId.ToString()); + if (role != null) + { + await _userManager.AddToRoleAsync(user, role.Name!); + } + } + } + + if (!dto.IsActive) + { + await _userManager.SetLockoutEnabledAsync(user, true); + await _userManager.SetLockoutEndDateAsync(user, DateTimeOffset.MaxValue); + } + + await CreateAuditLog("user", "create", "User", user.Id, user.UserName, null, SerializeToJson(dto)); + + return CreatedAtAction(nameof(GetUser), new { id = user.Id }, user); + } + + [HttpPut("{id}")] + public async Task UpdateUser(long id, UpdateUserDto dto) + { + var user = await _context.Users.FindAsync(id); + if (user == null) + { + return NotFound(); + } + + var oldValue = System.Text.Json.JsonSerializer.Serialize(user); + + user.Email = dto.Email; + user.RealName = dto.RealName; + user.Phone = dto.Phone; + user.EmailConfirmed = dto.EmailConfirmed; + user.UpdatedTime = DateTime.UtcNow; + + if (dto.IsActive) + { + await _userManager.SetLockoutEnabledAsync(user, false); + await _userManager.SetLockoutEndDateAsync(user, null); + } + else + { + await _userManager.SetLockoutEnabledAsync(user, true); + await _userManager.SetLockoutEndDateAsync(user, DateTimeOffset.MaxValue); + } + + await _context.SaveChangesAsync(); + + await CreateAuditLog("user", "update", "User", user.Id, user.UserName, oldValue, System.Text.Json.JsonSerializer.Serialize(user)); + + return NoContent(); + } + + [HttpPut("{id}/password")] + public async Task ResetPassword(long id, ResetPasswordDto dto) + { + var user = await _userManager.FindByIdAsync(id.ToString()); + if (user == null) + { + return NotFound(); + } + + var token = await _userManager.GeneratePasswordResetTokenAsync(user); + var result = await _userManager.ResetPasswordAsync(user, token, dto.NewPassword); + + if (!result.Succeeded) + { + return BadRequest(result.Errors); + } + + await CreateAuditLog("user", "reset_password", "User", user.Id, user.UserName); + + return NoContent(); + } + + [HttpDelete("{id}")] + public async Task DeleteUser(long id) + { + var user = await _context.Users.FindAsync(id); + if (user == null) + { + return NotFound(); + } + + var oldValue = System.Text.Json.JsonSerializer.Serialize(user); + user.IsDeleted = true; + user.UpdatedTime = DateTime.UtcNow; + await _context.SaveChangesAsync(); + + await CreateAuditLog("user", "delete", "User", user.Id, user.UserName, oldValue); + + return NoContent(); + } + + private async Task CreateAuditLog(string operation, string action, string targetType, long? targetId, string? targetName, string? oldValue = null, string? newValue = null) + { + var userName = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.Identity?.Name ?? "system"; + var tenantId = User.FindFirstValue("TenantId"); + + var log = new AuditLog + { + Operator = userName, + TenantId = tenantId, + Operation = operation, + Action = action, + TargetType = targetType, + TargetId = targetId, + TargetName = targetName, + IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + Status = "success", + OldValue = oldValue, + NewValue = newValue, + }; + + _context.AuditLogs.Add(log); + await _context.SaveChangesAsync(); + } + + private string SerializeToJson(object obj) + { + return System.Text.Json.JsonSerializer.Serialize(obj, new System.Text.Json.JsonSerializerOptions + { + WriteIndented = false + }); + } +} + +public class CreateUserDto +{ + public string UserName { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string RealName { get; set; } = string.Empty; + public string? Phone { get; set; } + public long? TenantId { get; set; } + public List RoleIds { get; set; } = new(); + public string Password { get; set; } = string.Empty; + public bool EmailConfirmed { get; set; } + public bool IsActive { get; set; } = true; +} + +public class UpdateUserDto +{ + public string Email { get; set; } = string.Empty; + public string RealName { get; set; } = string.Empty; + public string? Phone { get; set; } + public bool EmailConfirmed { get; set; } + public bool IsActive { get; set; } = true; +} + +public class ResetPasswordDto +{ + public string NewPassword { get; set; } = string.Empty; +} diff --git a/src/Fengling.AuthService/DTOs/LoginRequest.cs b/src/Fengling.AuthService/DTOs/LoginRequest.cs deleted file mode 100644 index 9228867..0000000 --- a/src/Fengling.AuthService/DTOs/LoginRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Fengling.AuthService.DTOs; - -public class LoginRequest -{ - public string UserName { get; set; } = string.Empty; - public string Password { get; set; } = string.Empty; - public long TenantId { get; set; } -} diff --git a/src/Fengling.AuthService/DTOs/LoginResponse.cs b/src/Fengling.AuthService/DTOs/LoginResponse.cs deleted file mode 100644 index c1f95fd..0000000 --- a/src/Fengling.AuthService/DTOs/LoginResponse.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Fengling.AuthService.DTOs; - -public class LoginResponse -{ - public string AccessToken { get; set; } = string.Empty; - public string RefreshToken { get; set; } = string.Empty; - public int ExpiresIn { get; set; } - public string TokenType { get; set; } = "Bearer"; -} diff --git a/src/Fengling.AuthService/Data/ApplicationDbContext.cs b/src/Fengling.AuthService/Data/ApplicationDbContext.cs index d11a006..dd4e1d5 100644 --- a/src/Fengling.AuthService/Data/ApplicationDbContext.cs +++ b/src/Fengling.AuthService/Data/ApplicationDbContext.cs @@ -12,6 +12,9 @@ public class ApplicationDbContext : IdentityDbContext OAuthApplications { get; set; } + public DbSet Tenants { get; set; } + public DbSet AccessLogs { get; set; } + public DbSet AuditLogs { get; set; } protected override void OnModelCreating(ModelBuilder builder) { @@ -40,6 +43,57 @@ public class ApplicationDbContext : IdentityDbContext e.ClientType).HasMaxLength(20); entity.Property(e => e.ConsentType).HasMaxLength(20); entity.Property(e => e.Status).HasMaxLength(20); + entity.Property(e => e.Description).HasMaxLength(500); + }); + + builder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.TenantId).IsUnique(); + entity.Property(e => e.TenantId).HasMaxLength(50); + entity.Property(e => e.Name).HasMaxLength(100); + entity.Property(e => e.ContactName).HasMaxLength(50); + entity.Property(e => e.ContactEmail).HasMaxLength(100); + entity.Property(e => e.ContactPhone).HasMaxLength(20); + entity.Property(e => e.Status).HasMaxLength(20); + entity.Property(e => e.Description).HasMaxLength(500); + }); + + builder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.CreatedAt); + entity.HasIndex(e => e.UserName); + entity.HasIndex(e => e.TenantId); + entity.HasIndex(e => e.Action); + entity.HasIndex(e => e.Status); + entity.Property(e => e.UserName).HasMaxLength(50); + entity.Property(e => e.TenantId).HasMaxLength(50); + entity.Property(e => e.Action).HasMaxLength(20); + entity.Property(e => e.Resource).HasMaxLength(200); + entity.Property(e => e.Method).HasMaxLength(10); + entity.Property(e => e.IpAddress).HasMaxLength(50); + entity.Property(e => e.UserAgent).HasMaxLength(500); + entity.Property(e => e.Status).HasMaxLength(20); + }); + + builder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.CreatedAt); + entity.HasIndex(e => e.Operator); + entity.HasIndex(e => e.TenantId); + entity.HasIndex(e => e.Operation); + entity.HasIndex(e => e.Action); + entity.Property(e => e.Operator).HasMaxLength(50); + entity.Property(e => e.TenantId).HasMaxLength(50); + entity.Property(e => e.Operation).HasMaxLength(20); + entity.Property(e => e.Action).HasMaxLength(20); + entity.Property(e => e.TargetType).HasMaxLength(50); + entity.Property(e => e.TargetName).HasMaxLength(100); + entity.Property(e => e.IpAddress).HasMaxLength(50); + entity.Property(e => e.Description).HasMaxLength(500); + entity.Property(e => e.Status).HasMaxLength(20); }); } } diff --git a/src/Fengling.AuthService/Data/Migrations/20260202031310_AddTenantAndLogs.Designer.cs b/src/Fengling.AuthService/Data/Migrations/20260202031310_AddTenantAndLogs.Designer.cs new file mode 100644 index 0000000..b03ca1a --- /dev/null +++ b/src/Fengling.AuthService/Data/Migrations/20260202031310_AddTenantAndLogs.Designer.cs @@ -0,0 +1,621 @@ +// +using System; +using System.Collections.Generic; +using Fengling.AuthService.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fengling.AuthService.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260202031310_AddTenantAndLogs")] + partial class AddTenantAndLogs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Fengling.AuthService.Models.AccessLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("integer"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Method") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("RequestData") + .HasColumnType("text"); + + b.Property("Resource") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ResponseData") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UserName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserName"); + + b.ToTable("AccessLogs"); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.PrimitiveCollection>("Permissions") + .HasColumnType("text[]"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("RealName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("Phone") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NewValue") + .HasColumnType("text"); + + b.Property("OldValue") + .HasColumnType("text"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TargetId") + .HasColumnType("bigint"); + + b.Property("TargetName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Operation"); + + b.HasIndex("Operator"); + + b.HasIndex("TenantId"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.OAuthApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ConsentType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.PrimitiveCollection("GrantTypes") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("PostLogoutRedirectUris") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("RedirectUris") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("Scopes") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OAuthApplications"); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ContactPhone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MaxUsers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.ApplicationUser", b => + { + b.HasOne("Fengling.AuthService.Models.Tenant", null) + .WithMany("Users") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Fengling.AuthService.Models.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Fengling.AuthService.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Fengling.AuthService.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Fengling.AuthService.Models.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Fengling.AuthService.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Fengling.AuthService.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.Tenant", b => + { + b.Navigation("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Fengling.AuthService/Data/Migrations/20260202031310_AddTenantAndLogs.cs b/src/Fengling.AuthService/Data/Migrations/20260202031310_AddTenantAndLogs.cs new file mode 100644 index 0000000..5a8aa57 --- /dev/null +++ b/src/Fengling.AuthService/Data/Migrations/20260202031310_AddTenantAndLogs.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fengling.AuthService.Data.Migrations +{ + /// + public partial class AddTenantAndLogs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DisplayName", + table: "AspNetRoles", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsSystem", + table: "AspNetRoles", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn>( + name: "Permissions", + table: "AspNetRoles", + type: "text[]", + nullable: true); + + migrationBuilder.AddColumn( + name: "TenantId", + table: "AspNetRoles", + type: "bigint", + nullable: true); + + migrationBuilder.CreateTable( + name: "AccessLogs", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserName = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + TenantId = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Action = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Resource = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Method = table.Column(type: "character varying(10)", maxLength: 10, nullable: true), + IpAddress = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + UserAgent = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Duration = table.Column(type: "integer", nullable: false), + RequestData = table.Column(type: "text", nullable: true), + ResponseData = table.Column(type: "text", nullable: true), + ErrorMessage = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AccessLogs", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AuditLogs", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Operator = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + TenantId = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Operation = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Action = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + TargetType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + TargetId = table.Column(type: "bigint", nullable: true), + TargetName = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + IpAddress = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Description = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + OldValue = table.Column(type: "text", nullable: true), + NewValue = table.Column(type: "text", nullable: true), + ErrorMessage = table.Column(type: "text", nullable: true), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditLogs", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Tenants", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + TenantId = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + ContactName = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + ContactEmail = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + ContactPhone = table.Column(type: "character varying(20)", maxLength: 20, nullable: true), + MaxUsers = table.Column(type: "integer", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + Description = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: true), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + IsDeleted = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tenants", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_AccessLogs_Action", + table: "AccessLogs", + column: "Action"); + + migrationBuilder.CreateIndex( + name: "IX_AccessLogs_CreatedAt", + table: "AccessLogs", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_AccessLogs_Status", + table: "AccessLogs", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_AccessLogs_TenantId", + table: "AccessLogs", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_AccessLogs_UserName", + table: "AccessLogs", + column: "UserName"); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_Action", + table: "AuditLogs", + column: "Action"); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_CreatedAt", + table: "AuditLogs", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_Operation", + table: "AuditLogs", + column: "Operation"); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_Operator", + table: "AuditLogs", + column: "Operator"); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_TenantId", + table: "AuditLogs", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_Tenants_TenantId", + table: "Tenants", + column: "TenantId", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_AspNetUsers_Tenants_TenantId", + table: "AspNetUsers", + column: "TenantId", + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_AspNetUsers_Tenants_TenantId", + table: "AspNetUsers"); + + migrationBuilder.DropTable( + name: "AccessLogs"); + + migrationBuilder.DropTable( + name: "AuditLogs"); + + migrationBuilder.DropTable( + name: "Tenants"); + + migrationBuilder.DropColumn( + name: "DisplayName", + table: "AspNetRoles"); + + migrationBuilder.DropColumn( + name: "IsSystem", + table: "AspNetRoles"); + + migrationBuilder.DropColumn( + name: "Permissions", + table: "AspNetRoles"); + + migrationBuilder.DropColumn( + name: "TenantId", + table: "AspNetRoles"); + } + } +} diff --git a/src/Fengling.AuthService/Data/Migrations/20260202064650_AddOAuthDescription.Designer.cs b/src/Fengling.AuthService/Data/Migrations/20260202064650_AddOAuthDescription.Designer.cs new file mode 100644 index 0000000..47f017a --- /dev/null +++ b/src/Fengling.AuthService/Data/Migrations/20260202064650_AddOAuthDescription.Designer.cs @@ -0,0 +1,625 @@ +// +using System; +using System.Collections.Generic; +using Fengling.AuthService.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fengling.AuthService.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260202064650_AddOAuthDescription")] + partial class AddOAuthDescription + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Fengling.AuthService.Models.AccessLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("integer"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Method") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("RequestData") + .HasColumnType("text"); + + b.Property("Resource") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ResponseData") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UserName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserName"); + + b.ToTable("AccessLogs"); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.PrimitiveCollection>("Permissions") + .HasColumnType("text[]"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("RealName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("Phone") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NewValue") + .HasColumnType("text"); + + b.Property("OldValue") + .HasColumnType("text"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TargetId") + .HasColumnType("bigint"); + + b.Property("TargetName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Operation"); + + b.HasIndex("Operator"); + + b.HasIndex("TenantId"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.OAuthApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ConsentType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.PrimitiveCollection("GrantTypes") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("PostLogoutRedirectUris") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("RedirectUris") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("Scopes") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OAuthApplications"); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ContactPhone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MaxUsers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.ApplicationUser", b => + { + b.HasOne("Fengling.AuthService.Models.Tenant", null) + .WithMany("Users") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Fengling.AuthService.Models.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Fengling.AuthService.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Fengling.AuthService.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Fengling.AuthService.Models.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Fengling.AuthService.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Fengling.AuthService.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.Tenant", b => + { + b.Navigation("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Fengling.AuthService/Data/Migrations/20260202064650_AddOAuthDescription.cs b/src/Fengling.AuthService/Data/Migrations/20260202064650_AddOAuthDescription.cs new file mode 100644 index 0000000..9f6d766 --- /dev/null +++ b/src/Fengling.AuthService/Data/Migrations/20260202064650_AddOAuthDescription.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Fengling.AuthService.Data.Migrations +{ + /// + public partial class AddOAuthDescription : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Description", + table: "OAuthApplications", + type: "character varying(500)", + maxLength: 500, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Description", + table: "OAuthApplications"); + } + } +} diff --git a/src/Fengling.AuthService/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Fengling.AuthService/Data/Migrations/ApplicationDbContextModelSnapshot.cs index 23a3a85..2dd38f3 100644 --- a/src/Fengling.AuthService/Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Fengling.AuthService/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1,5 +1,6 @@ // using System; +using System.Collections.Generic; using Fengling.AuthService.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -22,6 +23,78 @@ namespace Fengling.AuthService.Data.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("Fengling.AuthService.Models.AccessLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("integer"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Method") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("RequestData") + .HasColumnType("text"); + + b.Property("Resource") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ResponseData") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UserName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserName"); + + b.ToTable("AccessLogs"); + }); + modelBuilder.Entity("Fengling.AuthService.Models.ApplicationRole", b => { b.Property("Id") @@ -41,6 +114,12 @@ namespace Fengling.AuthService.Data.Migrations .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + b.Property("Name") .HasMaxLength(256) .HasColumnType("character varying(256)"); @@ -49,6 +128,12 @@ namespace Fengling.AuthService.Data.Migrations .HasMaxLength(256) .HasColumnType("character varying(256)"); + b.PrimitiveCollection>("Permissions") + .HasColumnType("text[]"); + + b.Property("TenantId") + .HasColumnType("bigint"); + b.HasKey("Id"); b.HasIndex("NormalizedName") @@ -150,6 +235,85 @@ namespace Fengling.AuthService.Data.Migrations b.ToTable("AspNetUsers", (string)null); }); + modelBuilder.Entity("Fengling.AuthService.Models.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NewValue") + .HasColumnType("text"); + + b.Property("OldValue") + .HasColumnType("text"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TargetId") + .HasColumnType("bigint"); + + b.Property("TargetName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Operation"); + + b.HasIndex("Operator"); + + b.HasIndex("TenantId"); + + b.ToTable("AuditLogs"); + }); + modelBuilder.Entity("Fengling.AuthService.Models.OAuthApplication", b => { b.Property("Id") @@ -180,6 +344,10 @@ namespace Fengling.AuthService.Data.Migrations b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + b.Property("DisplayName") .IsRequired() .HasMaxLength(100) @@ -217,6 +385,70 @@ namespace Fengling.AuthService.Data.Migrations b.ToTable("OAuthApplications"); }); + modelBuilder.Entity("Fengling.AuthService.Models.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ContactPhone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MaxUsers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("Tenants"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.Property("Id") @@ -320,6 +552,15 @@ namespace Fengling.AuthService.Data.Migrations b.ToTable("AspNetUserTokens", (string)null); }); + modelBuilder.Entity("Fengling.AuthService.Models.ApplicationUser", b => + { + b.HasOne("Fengling.AuthService.Models.Tenant", null) + .WithMany("Users") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Fengling.AuthService.Models.ApplicationRole", null) @@ -370,6 +611,11 @@ namespace Fengling.AuthService.Data.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); + + modelBuilder.Entity("Fengling.AuthService.Models.Tenant", b => + { + b.Navigation("Users"); + }); #pragma warning restore 612, 618 } } diff --git a/src/Fengling.AuthService/Data/SeedData.cs b/src/Fengling.AuthService/Data/SeedData.cs index cd659a2..017c766 100644 --- a/src/Fengling.AuthService/Data/SeedData.cs +++ b/src/Fengling.AuthService/Data/SeedData.cs @@ -15,18 +15,65 @@ public static class SeedData context.Database.EnsureCreated(); + var defaultTenant = await context.Tenants + .FirstOrDefaultAsync(t => t.TenantId == "default"); + if (defaultTenant == null) + { + defaultTenant = new Tenant + { + TenantId = "default", + Name = "默认租户", + ContactName = "系统管理员", + ContactEmail = "admin@fengling.local", + ContactPhone = "13800138000", + MaxUsers = 1000, + Description = "系统默认租户", + Status = "active", + CreatedAt = DateTime.UtcNow + }; + context.Tenants.Add(defaultTenant); + await context.SaveChangesAsync(); + } + var adminRole = await roleManager.FindByNameAsync("Admin"); if (adminRole == null) { adminRole = new ApplicationRole { Name = "Admin", + DisplayName = "管理员", Description = "System administrator", + TenantId = defaultTenant.Id, + IsSystem = true, + Permissions = new List + { + "user.manage", "user.view", + "role.manage", "role.view", + "tenant.manage", "tenant.view", + "oauth.manage", "oauth.view", + "log.view", "system.config" + }, CreatedTime = DateTime.UtcNow }; await roleManager.CreateAsync(adminRole); } + var userRole = await roleManager.FindByNameAsync("User"); + if (userRole == null) + { + userRole = new ApplicationRole + { + Name = "User", + DisplayName = "普通用户", + Description = "Regular user", + TenantId = defaultTenant.Id, + IsSystem = true, + Permissions = new List { "user.view" }, + CreatedTime = DateTime.UtcNow + }; + await roleManager.CreateAsync(userRole); + } + var adminUser = await userManager.FindByNameAsync("admin"); if (adminUser == null) { @@ -36,7 +83,7 @@ public static class SeedData Email = "admin@fengling.local", RealName = "系统管理员", Phone = "13800138000", - TenantId = 1, + TenantId = defaultTenant.Id, EmailConfirmed = true, IsDeleted = false, CreatedTime = DateTime.UtcNow @@ -58,7 +105,7 @@ public static class SeedData Email = "test@fengling.local", RealName = "测试用户", Phone = "13900139000", - TenantId = 1, + TenantId = defaultTenant.Id, EmailConfirmed = true, IsDeleted = false, CreatedTime = DateTime.UtcNow @@ -67,13 +114,6 @@ public static class SeedData var result = await userManager.CreateAsync(testUser, "Test@123"); if (result.Succeeded) { - var userRole = new ApplicationRole - { - Name = "User", - Description = "普通用户", - CreatedTime = DateTime.UtcNow - }; - await roleManager.CreateAsync(userRole); await userManager.AddToRoleAsync(testUser, "User"); } } diff --git a/src/Fengling.AuthService/Fengling.AuthService.csproj b/src/Fengling.AuthService/Fengling.AuthService.csproj index e31fbd7..30dfedf 100644 --- a/src/Fengling.AuthService/Fengling.AuthService.csproj +++ b/src/Fengling.AuthService/Fengling.AuthService.csproj @@ -6,23 +6,30 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - - - - - - + + + + + + + + + + + + + PreserveNewest + diff --git a/src/Fengling.AuthService/Models/AccessLog.cs b/src/Fengling.AuthService/Models/AccessLog.cs new file mode 100644 index 0000000..d0c4a2e --- /dev/null +++ b/src/Fengling.AuthService/Models/AccessLog.cs @@ -0,0 +1,43 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fengling.AuthService.Models; + +public class AccessLog +{ + [Key] + public long Id { get; set; } + + [MaxLength(50)] + public string? UserName { get; set; } + + [MaxLength(50)] + public string? TenantId { get; set; } + + [MaxLength(20)] + public string Action { get; set; } = string.Empty; + + [MaxLength(200)] + public string? Resource { get; set; } + + [MaxLength(10)] + public string? Method { get; set; } + + [MaxLength(50)] + public string? IpAddress { get; set; } + + [MaxLength(500)] + public string? UserAgent { get; set; } + + [MaxLength(20)] + public string Status { get; set; } = "success"; + + public int Duration { get; set; } + + public string? RequestData { get; set; } + + public string? ResponseData { get; set; } + + public string? ErrorMessage { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Fengling.AuthService/Models/ApplicationRole.cs b/src/Fengling.AuthService/Models/ApplicationRole.cs index 612de44..a015315 100644 --- a/src/Fengling.AuthService/Models/ApplicationRole.cs +++ b/src/Fengling.AuthService/Models/ApplicationRole.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Identity; +using System.ComponentModel.DataAnnotations.Schema; namespace Fengling.AuthService.Models; @@ -6,4 +7,8 @@ public class ApplicationRole : IdentityRole { public string? Description { get; set; } public DateTime CreatedTime { get; set; } = DateTime.UtcNow; + public long? TenantId { get; set; } + public bool IsSystem { get; set; } + public string? DisplayName { get; set; } + public List? Permissions { get; set; } } diff --git a/src/Fengling.AuthService/Models/AuditLog.cs b/src/Fengling.AuthService/Models/AuditLog.cs new file mode 100644 index 0000000..60708c6 --- /dev/null +++ b/src/Fengling.AuthService/Models/AuditLog.cs @@ -0,0 +1,47 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fengling.AuthService.Models; + +public class AuditLog +{ + [Key] + public long Id { get; set; } + + [MaxLength(50)] + [Required] + public string Operator { get; set; } = string.Empty; + + [MaxLength(50)] + public string? TenantId { get; set; } + + [MaxLength(20)] + public string Operation { get; set; } = string.Empty; + + [MaxLength(20)] + public string Action { get; set; } = string.Empty; + + [MaxLength(50)] + public string? TargetType { get; set; } + + public long? TargetId { get; set; } + + [MaxLength(100)] + public string? TargetName { get; set; } + + [MaxLength(50)] + public string IpAddress { get; set; } = string.Empty; + + [MaxLength(500)] + public string? Description { get; set; } + + public string? OldValue { get; set; } + + public string? NewValue { get; set; } + + public string? ErrorMessage { get; set; } + + [MaxLength(20)] + public string Status { get; set; } = "success"; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Fengling.AuthService/Models/OAuthApplication.cs b/src/Fengling.AuthService/Models/OAuthApplication.cs index 2b79d74..59d3b11 100644 --- a/src/Fengling.AuthService/Models/OAuthApplication.cs +++ b/src/Fengling.AuthService/Models/OAuthApplication.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations; + namespace Fengling.AuthService.Models; public class OAuthApplication @@ -13,6 +15,7 @@ public class OAuthApplication public string ClientType { get; set; } = "public"; public string ConsentType { get; set; } = "implicit"; public string Status { get; set; } = "active"; + public string? Description { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? UpdatedAt { get; set; } } diff --git a/src/Fengling.AuthService/Models/Tenant.cs b/src/Fengling.AuthService/Models/Tenant.cs new file mode 100644 index 0000000..d25c7c2 --- /dev/null +++ b/src/Fengling.AuthService/Models/Tenant.cs @@ -0,0 +1,47 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fengling.AuthService.Models; + +public class Tenant +{ + [Key] + public long Id { get; set; } + + [MaxLength(50)] + [Required] + public string TenantId { get; set; } = string.Empty; + + [MaxLength(100)] + [Required] + public string Name { get; set; } = string.Empty; + + [MaxLength(50)] + [Required] + public string ContactName { get; set; } = string.Empty; + + [MaxLength(100)] + [Required] + [EmailAddress] + public string ContactEmail { get; set; } = string.Empty; + + [MaxLength(20)] + public string? ContactPhone { get; set; } + + public int? MaxUsers { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [MaxLength(500)] + public string? Description { get; set; } + + [MaxLength(20)] + public string Status { get; set; } = "active"; + + public DateTime? ExpiresAt { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public bool IsDeleted { get; set; } + + public ICollection Users { get; set; } = new List(); +} diff --git a/src/Fengling.AuthService/Program.cs b/src/Fengling.AuthService/Program.cs index eaa2ba4..3640d60 100644 --- a/src/Fengling.AuthService/Program.cs +++ b/src/Fengling.AuthService/Program.cs @@ -1,9 +1,10 @@ using Fengling.AuthService.Configuration; using Fengling.AuthService.Data; using Fengling.AuthService.Models; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using OpenTelemetry; using OpenTelemetry.Resources; using OpenTelemetry.Trace; @@ -19,13 +20,37 @@ Log.Logger = new LoggerConfiguration() builder.Host.UseSerilog(); +var connectionString = builder.Configuration.GetConnectionString("DefaultConnection"); builder.Services.AddDbContext(options => - options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); +{ + if (connectionString.StartsWith("DataSource=")) + { + options.UseInMemoryDatabase(connectionString); + } + else + { + options.UseNpgsql(connectionString); + } +}); + +builder.Services.AddRazorPages(); +builder.Services.AddControllersWithViews(); builder.Services.AddIdentity() .AddEntityFrameworkStores() .AddDefaultTokenProviders(); +builder.Services.AddAuthentication(options => +{ + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; +}).AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => +{ + options.Cookie.Name = "Fengling.Auth"; + options.Cookie.SecurePolicy = CookieSecurePolicy.None; + options.Cookie.SameSite = SameSiteMode.Lax; + options.ExpireTimeSpan = TimeSpan.FromDays(7); +}); + builder.Services.AddOpenIddictConfiguration(builder.Configuration); builder.Services.AddOpenTelemetry() @@ -37,7 +62,7 @@ builder.Services.AddOpenTelemetry() .AddSource("OpenIddict.Server.AspNetCore") .AddOtlpExporter()); -builder.Services.AddControllers(); +builder.Services.AddControllersWithViews(); builder.Services.AddHealthChecks() .AddNpgSql(builder.Configuration.GetConnectionString("DefaultConnection")!); @@ -71,18 +96,24 @@ using (var scope = app.Services.CreateScope()) await SeedData.Initialize(scope.ServiceProvider); } -app.UseSwagger(); -app.UseSwaggerUI(options => -{ - options.SwaggerEndpoint("/swagger/v1/swagger.json", "Fengling Auth Service v1"); - options.OAuthClientId("swagger-ui"); - options.OAuthUsePkce(); -}); - +app.UseStaticFiles(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); +var isTesting = builder.Configuration.GetValue("Testing", false); +if (!isTesting) +{ + app.UseSwagger(); + app.UseSwaggerUI(options => + { + options.SwaggerEndpoint("/swagger/v1/swagger.json", "Fengling Auth Service v1"); + options.OAuthClientId("swagger-ui"); + options.OAuthUsePkce(); + }); +} + +app.MapRazorPages(); app.MapControllers(); app.MapHealthChecks("/health"); diff --git a/src/Fengling.AuthService/Properties/launchSettings.json b/src/Fengling.AuthService/Properties/launchSettings.json index b8d0b90..8fa6f08 100644 --- a/src/Fengling.AuthService/Properties/launchSettings.json +++ b/src/Fengling.AuthService/Properties/launchSettings.json @@ -1,4 +1,4 @@ -{ +{ "$schema": "https://json.schemastore.org/launchsettings.json", "profiles": { "http": { @@ -9,15 +9,6 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:7150;http://localhost:5132", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } } } } diff --git a/src/Fengling.AuthService/ViewModels/AuthorizeViewModel.cs b/src/Fengling.AuthService/ViewModels/AuthorizeViewModel.cs new file mode 100644 index 0000000..088f5db --- /dev/null +++ b/src/Fengling.AuthService/ViewModels/AuthorizeViewModel.cs @@ -0,0 +1,6 @@ +namespace Fengling.AuthService.ViewModels; + +public record AuthorizeViewModel(string? ApplicationName, string? Scope) +{ + public string[]? Scopes => Scope?.Split(' ') ?? null; +} \ No newline at end of file diff --git a/src/Fengling.AuthService/ViewModels/DashboardViewModel.cs b/src/Fengling.AuthService/ViewModels/DashboardViewModel.cs new file mode 100644 index 0000000..5ff159b --- /dev/null +++ b/src/Fengling.AuthService/ViewModels/DashboardViewModel.cs @@ -0,0 +1,7 @@ +namespace Fengling.AuthService.ViewModels; + +public class DashboardViewModel +{ + public string? Username { get; set; } + public string? Email { get; set; } +} diff --git a/src/Fengling.AuthService/ViewModels/LoginViewModel.cs b/src/Fengling.AuthService/ViewModels/LoginViewModel.cs new file mode 100644 index 0000000..bddc0fd --- /dev/null +++ b/src/Fengling.AuthService/ViewModels/LoginViewModel.cs @@ -0,0 +1,14 @@ +namespace Fengling.AuthService.ViewModels; + +public class LoginViewModel +{ + public string ReturnUrl { get; set; } +} + +public class LoginInputModel +{ + public string Username { get; set; } + public string Password { get; set; } + public bool RememberMe { get; set; } + public string ReturnUrl { get; set; } +} \ No newline at end of file diff --git a/src/Fengling.AuthService/ViewModels/RegisterViewModel.cs b/src/Fengling.AuthService/ViewModels/RegisterViewModel.cs new file mode 100644 index 0000000..555da86 --- /dev/null +++ b/src/Fengling.AuthService/ViewModels/RegisterViewModel.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fengling.AuthService.ViewModels; + +public class RegisterViewModel +{ + [Required(ErrorMessage = "用户名不能为空")] + [StringLength(50, MinimumLength = 3, ErrorMessage = "用户名长度必须在3-50个字符之间")] + public string Username { get; set; } + + [Required(ErrorMessage = "邮箱不能为空")] + [EmailAddress(ErrorMessage = "请输入有效的邮箱地址")] + public string Email { get; set; } + + [Required(ErrorMessage = "密码不能为空")] + [StringLength(100, MinimumLength = 6, ErrorMessage = "密码长度必须在6-100个字符之间")] + [DataType(DataType.Password)] + public string Password { get; set; } + + [Required(ErrorMessage = "确认密码不能为空")] + [DataType(DataType.Password)] + [Compare("Password", ErrorMessage = "两次输入的密码不一致")] + public string ConfirmPassword { get; set; } + + public string ReturnUrl { get; set; } +} \ No newline at end of file diff --git a/src/Fengling.AuthService/Views/Account/Login.cshtml b/src/Fengling.AuthService/Views/Account/Login.cshtml new file mode 100644 index 0000000..156b39f --- /dev/null +++ b/src/Fengling.AuthService/Views/Account/Login.cshtml @@ -0,0 +1,81 @@ + @model Fengling.AuthService.ViewModels.LoginInputModel + +@{ + Layout = "_Layout"; + ViewData["Title"] = "登录"; +} + +
+
+
+
+ + + + + +
+

欢迎回来

+

登录到 Fengling Auth

+
+ +
+ @if (!ViewData.ModelState.IsValid) + { +
+ @foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors)) + { +

@error.ErrorMessage

+ } +
+ } + +
+ + +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ 还没有账号? + 立即注册 +
+
+
diff --git a/src/Fengling.AuthService/Views/Account/Register.cshtml b/src/Fengling.AuthService/Views/Account/Register.cshtml new file mode 100644 index 0000000..cd58d86 --- /dev/null +++ b/src/Fengling.AuthService/Views/Account/Register.cshtml @@ -0,0 +1,95 @@ +@model Fengling.AuthService.ViewModels.RegisterViewModel + +@{ + Layout = "_Layout"; + ViewData["Title"] = "注册"; +} + +
+
+
+
+ + + + + + +
+

创建账号

+

加入 Fengling Auth

+
+ +
+ @if (!ViewData.ModelState.IsValid) + { +
+ @foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors)) + { +

@error.ErrorMessage

+ } +
+ } + +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ +
+ 已有账号? + 立即登录 +
+
+
diff --git a/src/Fengling.AuthService/Views/Authorization/Authorize.cshtml b/src/Fengling.AuthService/Views/Authorization/Authorize.cshtml new file mode 100644 index 0000000..dc4dda9 --- /dev/null +++ b/src/Fengling.AuthService/Views/Authorization/Authorize.cshtml @@ -0,0 +1,146 @@ +@model Fengling.AuthService.ViewModels.AuthorizeViewModel + +@{ + Layout = "_Layout"; + ViewData["Title"] = "授权确认"; +} + +
+
+ +
+
+ + + +
+

授权确认

+

+ @Model.ApplicationName + 请求访问您的账户 +

+
+ + +
+ +
+
+
+
+ @(Model.ApplicationName?.Substring(0, Math.Min(1, Model.ApplicationName.Length)).ToUpper() ?? "A") +
+
+
+

@Model.ApplicationName

+

+ 该应用将获得以下权限: +

+
+
+
+ + +
+

请求的权限

+
+ @if (Model.Scopes != null && Model.Scopes.Length > 0) + { + @foreach (var scope in Model.Scopes) + { +
+ + + + +
+

@GetScopeDisplayName(scope)

+

@GetScopeDescription(scope)

+
+
+ } + } + else + { +

无特定权限请求

+ } +
+
+ + +
+
+ + + + + +

+ 授予权限后,该应用将能够访问您的账户信息。您可以随时在授权管理中撤销权限。 +

+
+
+
+ + +
+
+ + +
+
+ + + +
+
+ +@functions { + private string GetScopeDisplayName(string scope) + { + return scope switch + { + "openid" => "OpenID Connect", + "profile" => "个人资料", + "email" => "电子邮件地址", + "phone" => "电话号码", + "address" => "地址信息", + "roles" => "角色权限", + "offline_access" => "离线访问", + _ => scope + }; + } + + private string GetScopeDescription(string scope) + { + return scope switch + { + "openid" => "用于用户身份验证", + "profile" => "访问您的姓名、头像等基本信息", + "email" => "访问您的电子邮件地址", + "phone" => "访问您的电话号码", + "address" => "访问您的地址信息", + "roles" => "访问您的角色和权限信息", + "offline_access" => "在您离线时仍可访问数据", + _ => "自定义权限范围" + }; + } +} diff --git a/src/Fengling.AuthService/Views/Dashboard/Index.cshtml b/src/Fengling.AuthService/Views/Dashboard/Index.cshtml new file mode 100644 index 0000000..03198a8 --- /dev/null +++ b/src/Fengling.AuthService/Views/Dashboard/Index.cshtml @@ -0,0 +1,155 @@ +@model Fengling.AuthService.ViewModels.DashboardViewModel + +@{ + Layout = "_Layout"; + ViewData["Title"] = "控制台"; +} + +
+
+

欢迎,@Model.Username

+

这里是您的控制台首页

+
+ +
+
+
+
+

已登录应用

+

3

+
+
+ + + + + +
+
+
+ +
+
+
+

授权次数

+

12

+
+
+ + + + +
+
+
+ +
+
+
+

活跃会话

+

5

+
+
+ + + + +
+
+
+ +
+
+
+

安全评分

+

92%

+
+
+ + + +
+
+
+
+ +
+
+

最近活动

+
+
+
+ F +
+
+

登录成功

+

通过 Fengling.Console.Web 登录

+
+ 2分钟前 +
+ +
+
+ ✓ +
+
+

授权成功

+

授予 Fengling.Console.Web 访问权限

+
+ 5分钟前 +
+ +
+
+ 🔄 +
+
+

令牌刷新

+

刷新访问令牌

+
+ 1小时前 +
+
+
+ +
+

快捷操作

+ +
+
+
diff --git a/src/Fengling.AuthService/Views/Dashboard/Profile.cshtml b/src/Fengling.AuthService/Views/Dashboard/Profile.cshtml new file mode 100644 index 0000000..bbeec08 --- /dev/null +++ b/src/Fengling.AuthService/Views/Dashboard/Profile.cshtml @@ -0,0 +1,50 @@ +@model Fengling.AuthService.ViewModels.DashboardViewModel + +@{ + Layout = "_Layout"; + ViewData["Title"] = "个人资料"; +} + +
+
+

个人资料

+

管理您的个人信息

+
+ +
+
+
+
+ @(Model.Username?.Substring(0, 1).ToUpper() ?? "U") +
+
+

@Model.Username

+

@Model.Email

+
+
+ +
+
+ +
+ @Model.Username +
+
+ +
+ +
+ @Model.Email +
+
+ +
+ +
+ 2026-01-15 +
+
+
+
+
+
diff --git a/src/Fengling.AuthService/Views/Dashboard/Settings.cshtml b/src/Fengling.AuthService/Views/Dashboard/Settings.cshtml new file mode 100644 index 0000000..d4e311d --- /dev/null +++ b/src/Fengling.AuthService/Views/Dashboard/Settings.cshtml @@ -0,0 +1,90 @@ +@model Fengling.AuthService.ViewModels.DashboardViewModel + +@{ + Layout = "_Layout"; + ViewData["Title"] = "设置"; +} + +
+
+

账户设置

+

管理您的账户设置和偏好

+
+ +
+
+

修改密码

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ +
+

安全选项

+
+
+
+

两步验证

+

为您的账户添加额外的安全保护

+
+ +
+ +
+
+

登录通知

+

当有新设备登录时发送通知

+
+ +
+
+
+ +
+

危险区域

+
+
+

删除账户

+

永久删除您的账户和所有数据

+
+ +
+
+
+
diff --git a/src/Fengling.AuthService/Views/Shared/_Layout.cshtml b/src/Fengling.AuthService/Views/Shared/_Layout.cshtml new file mode 100644 index 0000000..533acb9 --- /dev/null +++ b/src/Fengling.AuthService/Views/Shared/_Layout.cshtml @@ -0,0 +1,162 @@ + + + + + + + + + + @ViewData["Title"] - Fengling Auth + + + + + + + + + + +
+
+ +
+
+ + + + + +
+ Fengling Auth +
+ + + + + +
+ @if (User.Identity?.IsAuthenticated == true) + { + +
+ + + + +
+ } + else + { + + 登录 + + 注册 + + } +
+
+
+ + +
+ @RenderBody() +
+ + + + + + \ No newline at end of file diff --git a/src/Fengling.AuthService/Views/_ViewImports.cshtml b/src/Fengling.AuthService/Views/_ViewImports.cshtml new file mode 100644 index 0000000..1dbdf3d --- /dev/null +++ b/src/Fengling.AuthService/Views/_ViewImports.cshtml @@ -0,0 +1,3 @@ +@using Fengling.AuthService +@using Fengling.AuthService.ViewModels +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers \ No newline at end of file diff --git a/src/Fengling.AuthService/Views/_ViewStart.cshtml b/src/Fengling.AuthService/Views/_ViewStart.cshtml new file mode 100644 index 0000000..1af6e49 --- /dev/null +++ b/src/Fengling.AuthService/Views/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} \ No newline at end of file diff --git a/src/Fengling.AuthService/appsettings.Testing.json b/src/Fengling.AuthService/appsettings.Testing.json new file mode 100644 index 0000000..bcf5701 --- /dev/null +++ b/src/Fengling.AuthService/appsettings.Testing.json @@ -0,0 +1,22 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "DataSource=:memory:" + }, + "Jwt": { + "Issuer": "https://auth.fengling.local", + "Audience": "fengling-api", + "Secret": "FenglingAuthSecretKey2024!ChangeThisInProduction!" + }, + "OpenIddict": { + "Issuer": "https://auth.fengling.local", + "Audience": "fengling-api" + }, + "Testing": true, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Fengling.AuthService/appsettings.json b/src/Fengling.AuthService/appsettings.json index b0df93d..14eeb16 100644 --- a/src/Fengling.AuthService/appsettings.json +++ b/src/Fengling.AuthService/appsettings.json @@ -2,6 +2,11 @@ "ConnectionStrings": { "DefaultConnection": "Host=192.168.100.10;Port=5432;Database=fengling_auth;Username=movingsam;Password=sl52788542" }, + "Jwt": { + "Issuer": "https://auth.fengling.local", + "Audience": "fengling-api", + "Secret": "FenglingAuthSecretKey2024!ChangeThisInProduction!" + }, "OpenIddict": { "Issuer": "https://auth.fengling.local", "Audience": "fengling-api" diff --git a/src/Fengling.AuthService/wwwroot/css/styles.css b/src/Fengling.AuthService/wwwroot/css/styles.css new file mode 100644 index 0000000..e3191b4 --- /dev/null +++ b/src/Fengling.AuthService/wwwroot/css/styles.css @@ -0,0 +1,210 @@ +/* ============================================ + shadcn UI Theme Variables + Based on shadcn/ui default theme + ============================================ */ + +/* Light Mode Variables */ +:root { + /* Background & Foreground */ + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + + /* Card */ + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + + /* Popover */ + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + + /* Primary */ + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + + /* Secondary */ + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + /* Muted */ + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + /* Accent */ + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + /* Destructive */ + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + + /* Borders & Inputs */ + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + + /* Ring */ + --ring: 222.2 84% 4.9%; + + /* Radius */ + --radius: 0.5rem; +} + +/* Dark Mode Variables */ +.dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; +} + +/* ============================================ + Base Styles + ============================================ */ + +* { + border-color: hsl(var(--border)); +} + +body { + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + font-feature-settings: "rlig" 1, "calt" 1; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ============================================ + Utility Classes + ============================================ */ + +/* Background colors */ +.bg-primary { + background-color: hsl(var(--primary)); +} + +.bg-secondary { + background-color: hsl(var(--secondary)); +} + +.bg-muted { + background-color: hsl(var(--muted)); +} + +.bg-accent { + background-color: hsl(var(--accent)); +} + +.bg-destructive { + background-color: hsl(var(--destructive)); +} + +/* Text colors */ +.text-primary-foreground { + color: hsl(var(--primary-foreground)); +} + +.text-secondary-foreground { + color: hsl(var(--secondary-foreground)); +} + +.text-muted-foreground { + color: hsl(var(--muted-foreground)); +} + +.text-accent-foreground { + color: hsl(var(--accent-foreground)); +} + +.text-destructive-foreground { + color: hsl(var(--destructive-foreground)); +} + +.text-foreground { + color: hsl(var(--foreground)); +} + +/* Borders */ +.border-border { + border-color: hsl(var(--border)); +} + +/* Hover effects */ +.hover\:bg-muted:hover { + background-color: hsl(var(--muted)); +} + +.hover\:bg-primary:hover { + background-color: hsl(var(--primary)); +} + +.hover\:text-foreground:hover { + color: hsl(var(--foreground)); +} + +/* ============================================ + Transitions + ============================================ */ + +.transition-colors { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.transition-all { + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +/* ============================================ + Component Styles + ============================================ */ + +/* Button Styles */ +.btn-primary { + background-color: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); +} + +.btn-primary:hover { + background-color: hsl(var(--primary) / 0.9); +} + +/* Card Styles */ +.card { + background-color: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: var(--radius); +} + +.card-foreground { + color: hsl(var(--card-foreground)); +} + +/* Input Styles */ +.input { + background-color: hsl(var(--background)); + border: 1px solid hsl(var(--input)); + color: hsl(var(--foreground)); +} + +.input:focus { + outline: none; + border-color: hsl(var(--ring)); + box-shadow: 0 0 0 2px hsl(var(--ring) / 0.2); +} diff --git a/src/Fengling.AuthService/wwwroot/login.html b/src/Fengling.AuthService/wwwroot/login.html new file mode 100644 index 0000000..2bf3304 --- /dev/null +++ b/src/Fengling.AuthService/wwwroot/login.html @@ -0,0 +1,193 @@ + + + + + + 登录 - 风铃认证服务 + + + +
+

风铃认证服务

+ +
+ +
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + + diff --git a/src/Fengling.Console.Web/.env.development b/src/Fengling.Console.Web/.env.development index 94f1497..8f1a7f7 100644 --- a/src/Fengling.Console.Web/.env.development +++ b/src/Fengling.Console.Web/.env.development @@ -1,7 +1,7 @@ # Fengling.Console.Web 运管中心前端 ## 开发环境配置 -VITE_AUTH_SERVICE_URL=http://localhost:5000 +VITE_AUTH_SERVER_URL=http://localhost:5132 VITE_GATEWAY_SERVICE_URL=http://localhost:5001 VITE_CLIENT_ID=fengling-console VITE_REDIRECT_URI=http://localhost:5173/auth/callback diff --git a/src/Fengling.Console.Web/.env.production b/src/Fengling.Console.Web/.env.production index caead6d..841ad76 100644 --- a/src/Fengling.Console.Web/.env.production +++ b/src/Fengling.Console.Web/.env.production @@ -1,7 +1,7 @@ # Fengling.Console.Web 运管中心前端 ## 生产环境配置 -VITE_AUTH_SERVICE_URL=https://auth.fengling.local +VITE_AUTH_SERVER_URL=https://auth.fengling.local VITE_GATEWAY_SERVICE_URL=https://gateway.fengling.local VITE_CLIENT_ID=fengling-console VITE_REDIRECT_URI=https://console.fengling.local/auth/callback diff --git a/src/Fengling.Console.Web/package-lock.json b/src/Fengling.Console.Web/package-lock.json index 14f191c..a48f4a9 100644 --- a/src/Fengling.Console.Web/package-lock.json +++ b/src/Fengling.Console.Web/package-lock.json @@ -8,6 +8,7 @@ "name": "fengling-console-web", "version": "0.0.0", "dependencies": { + "oidc-client-ts": "^3.4.1", "vue": "^3.5.24" }, "devDependencies": { @@ -1174,6 +1175,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1208,6 +1218,18 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/oidc-client-ts": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz", + "integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==", + "license": "Apache-2.0", + "dependencies": { + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", diff --git a/src/Fengling.Console.Web/package.json b/src/Fengling.Console.Web/package.json index 3bb165c..2f87665 100644 --- a/src/Fengling.Console.Web/package.json +++ b/src/Fengling.Console.Web/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "oidc-client-ts": "^3.4.1", "vue": "^3.5.24" }, "devDependencies": { diff --git a/src/Fengling.Console.Web/public/silent-renew.html b/src/Fengling.Console.Web/public/silent-renew.html new file mode 100644 index 0000000..1761eb6 --- /dev/null +++ b/src/Fengling.Console.Web/public/silent-renew.html @@ -0,0 +1,25 @@ + + + + + Silent Renew + + + + + + diff --git a/src/Fengling.Console.Web/src/App.vue b/src/Fengling.Console.Web/src/App.vue index 58b0f21..aff3dbb 100644 --- a/src/Fengling.Console.Web/src/App.vue +++ b/src/Fengling.Console.Web/src/App.vue @@ -1,30 +1,25 @@ - diff --git a/src/Fengling.Console.Web/src/api/auth.ts b/src/Fengling.Console.Web/src/api/auth.ts deleted file mode 100644 index d83401f..0000000 --- a/src/Fengling.Console.Web/src/api/auth.ts +++ /dev/null @@ -1,97 +0,0 @@ -import axios from 'axios' -import type { AxiosInstance } from 'axios' - -const authAxios: AxiosInstance = axios.create({ - baseURL: '/api/auth', - timeout: 10000, - headers: { - 'Content-Type': 'application/json', - }, -}) - -authAxios.interceptors.request.use( - config => { - const token = localStorage.getItem('access_token') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config - }, - error => { - return Promise.reject(error) - } -) - -export const authService = { - async login(username: string, password: string) { - const response = await authAxios.post('/connect/token', new URLSearchParams({ - grant_type: 'password', - username, - password, - scope: 'api offline_access', - }), { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }) - return response.data - }, - - async refreshToken(refreshToken: string) { - const response = await authAxios.post('/connect/token', new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - scope: 'api offline_access', - }), { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }) - return response.data - }, - - async logout() { - await authAxios.post('/connect/logout') - localStorage.removeItem('access_token') - localStorage.removeItem('refresh_token') - localStorage.removeItem('user_info') - }, - - async getClients() { - const response = await authAxios.get('/api/oauthclients') - return response.data - }, - - async createClient(client: any) { - const response = await authAxios.post('/api/oauthclients', client) - return response.data - }, - - async updateClient(id: number, client: any) { - const response = await authAxios.put(`/api/oauthclients/${id}`, client) - return response.data - }, - - async deleteClient(id: number) { - await authAxios.delete(`/api/oauthclients/${id}`) - }, - - initiateOAuthLogin() { - const clientId = import.meta.env.VITE_CLIENT_ID || 'fengling-console' - const redirectUri = import.meta.env.VITE_REDIRECT_URI || 'http://localhost:5173/auth/callback' - const scope = 'api offline_access' - - const params = new URLSearchParams({ - response_type: 'code', - client_id: clientId, - redirect_uri: redirectUri, - scope: scope, - state: Date.now().toString(), - }) - - const authServiceUrl = import.meta.env.VITE_AUTH_SERVICE_URL || 'http://localhost:5000' - window.location.href = `${authServiceUrl}/connect/authorize?${params.toString()}` - }, -} - -export default authAxios diff --git a/src/Fengling.Console.Web/src/api/index.ts b/src/Fengling.Console.Web/src/api/index.ts new file mode 100644 index 0000000..f838ddb --- /dev/null +++ b/src/Fengling.Console.Web/src/api/index.ts @@ -0,0 +1,49 @@ +import axios from 'axios' + +const api = axios.create({ + baseURL: '/api', + timeout: 30000, +}) + +api.interceptors.request.use( + (config) => { + const token = localStorage.getItem('access_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error) => { + return Promise.reject(error) + } +) + +api.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config + + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true + + try { + const { useAuthStore } = await import('@/stores/auth') + const authStore = useAuthStore() + await authStore.refresh() + + originalRequest.headers.Authorization = `Bearer ${localStorage.getItem('access_token')}` + return api(originalRequest) + } catch (refreshError) { + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + localStorage.removeItem('user_info') + window.location.href = '/login' + return Promise.reject(error) + } + } + + return Promise.reject(error) + } +) + +export default api diff --git a/src/Fengling.Console.Web/src/main.ts b/src/Fengling.Console.Web/src/main.ts index 2425c0f..a6da29f 100644 --- a/src/Fengling.Console.Web/src/main.ts +++ b/src/Fengling.Console.Web/src/main.ts @@ -1,5 +1,15 @@ import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' import './style.css' import App from './App.vue' +import router from './router' -createApp(App).mount('#app') +const app = createApp(App) + +app.use(createPinia()) +app.use(router) +app.use(ElementPlus) + +app.mount('#app') diff --git a/src/Fengling.Console.Web/src/router/index.ts b/src/Fengling.Console.Web/src/router/index.ts index fe1d2ae..dad6f53 100644 --- a/src/Fengling.Console.Web/src/router/index.ts +++ b/src/Fengling.Console.Web/src/router/index.ts @@ -17,45 +17,45 @@ const routes: Array = [ }, { path: '/', - name: 'Dashboard', component: () => import('@/views/Gateway/Dashboard.vue'), meta: { requiresAuth: true }, - }, - { - path: '/tenants', - name: 'TenantList', - component: () => import('@/views/Gateway/TenantList.vue'), - meta: { requiresAuth: true }, - }, - { - path: '/tenants/:tenantId/routes', - name: 'TenantRoutes', - component: () => import('@/views/Gateway/TenantRoutes.vue'), - meta: { requiresAuth: true }, - }, - { - path: '/clusters', - name: 'ClusterInstances', - component: () => import('@/views/Gateway/ClusterInstances.vue'), - meta: { requiresAuth: true }, - }, - { - path: '/routes/global', - name: 'GlobalRoutes', - component: () => import('@/views/Gateway/GlobalRoutes.vue'), - meta: { requiresAuth: true }, - }, - { - path: '/oauth/clients', - name: 'OAuthClients', - component: () => import('@/views/OAuth/ClientList.vue'), - meta: { requiresAuth: true }, - }, - { - path: '/users', - name: 'UserList', - component: () => import('@/views/Users/UserList.vue'), - meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'Dashboard', + component: () => import('@/views/Dashboard/Dashboard.vue'), + }, + { + path: 'oauth/clients', + name: 'OAuthClients', + component: () => import('@/views/OAuth/ClientList.vue'), + }, + { + path: 'users', + name: 'UserList', + component: () => import('@/views/Users/UserList.vue'), + }, + { + path: 'roles', + name: 'RoleList', + component: () => import('@/views/Users/RoleList.vue'), + }, + { + path: 'tenants', + name: 'TenantList', + component: () => import('@/views/Users/TenantList.vue'), + }, + { + path: 'logs/access', + name: 'AccessLog', + component: () => import('@/views/Audit/AccessLog.vue'), + }, + { + path: 'logs/audit', + name: 'AuditLog', + component: () => import('@/views/Audit/AuditLog.vue'), + }, + ], }, ] @@ -64,12 +64,16 @@ const router = createRouter({ routes, }) -router.beforeEach((to, _from, next) => { +router.beforeEach(async (to, _from, next) => { const authStore = useAuthStore() - - if (to.meta.requiresAuth && !authStore.isAuthenticated.value) { + + if (!authStore.accessToken && !authStore.user) { + await authStore.loadFromStorage() + } + + if (to.meta.requiresAuth && !authStore.isAuthenticated) { next({ name: 'Login' }) - } else if (to.name === 'Login' && authStore.isAuthenticated.value) { + } else if (to.name === 'Login' && authStore.isAuthenticated) { next({ name: 'Dashboard' }) } else { next() diff --git a/src/Fengling.Console.Web/src/services/oidc.ts b/src/Fengling.Console.Web/src/services/oidc.ts new file mode 100644 index 0000000..7e48a70 --- /dev/null +++ b/src/Fengling.Console.Web/src/services/oidc.ts @@ -0,0 +1,87 @@ +import { UserManager, User } from 'oidc-client-ts' + +const AUTH_SERVER = import.meta.env.VITE_AUTH_SERVER_URL || 'http://localhost:5000' +const REDIRECT_URI = import.meta.env.VITE_REDIRECT_URI || window.location.origin + '/auth/callback' +const CLIENT_ID = import.meta.env.VITE_CLIENT_ID || 'fengling-console' + +const userManager = new UserManager({ + authority: AUTH_SERVER, + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + response_type: 'code', + scope: 'openid profile api offline_access', + automaticSilentRenew: true, + silent_redirect_uri: `${window.location.origin}/silent-renew.html`, + loadUserInfo: true, +}) + +export interface UserInfo { + sub?: string + name?: string + email?: string + tenant_id?: string + role?: string | string[] + preferred_username?: string +} + +export interface AuthUser { + access_token: string + refresh_token: string + token_type: string + expires_at: number + profile: UserInfo | null + expired: boolean +} + +function mapUserToAuthUser(user: User | null): AuthUser | null { + if (!user) return null + + return { + access_token: user.access_token, + refresh_token: user.refresh_token || '', + token_type: user.token_type || 'Bearer', + expires_at: user.expires_at || 0, + profile: (user.profile as UserInfo) || null, + expired: user.expired ?? false, + } +} + +export const authService = { + async login(): Promise { + await userManager.signinRedirect() + }, + + async handleCallback(): Promise { + const user = await userManager.signinCallback() + if (!user) { + throw new Error('登录失败:无效的用户信息') + } + return mapUserToAuthUser(user)! + }, + + async getUser(): Promise { + const user = await userManager.getUser() + return mapUserToAuthUser(user) + }, + + async refresh(): Promise { + const user = await userManager.signinSilent() + if (!user) { + throw new Error('刷新令牌失败') + } + return mapUserToAuthUser(user)! + }, + + async logout(): Promise { + await userManager.signoutRedirect() + }, + + async removeUser(): Promise { + await userManager.removeUser() + }, + + async isAuthenticated(): Promise { + const user = await userManager.getUser() + return user !== null && !user.expired + }, +} diff --git a/src/Fengling.Console.Web/src/stores/auth.ts b/src/Fengling.Console.Web/src/stores/auth.ts index 95728f0..6bd23e5 100644 --- a/src/Fengling.Console.Web/src/stores/auth.ts +++ b/src/Fengling.Console.Web/src/stores/auth.ts @@ -1,43 +1,70 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' +import { authService, type AuthUser } from '@/services/oidc' interface User { - id: number + id: string userName: string email: string - realName: string - tenantId: number + tenantId: string roles: string[] } export const useAuthStore = defineStore('auth', () => { - const accessToken = ref(null) - const refreshToken = ref(null) + const accessToken = ref('') + const refreshToken = ref('') const user = ref(null) - const isAuthenticated = computed(() => !!accessToken.value) + const isAuthenticated = computed(() => !!accessToken.value && !!user.value) - function setTokens(accessTokenValue: string, refreshTokenValue: string) { - accessToken.value = accessTokenValue - refreshToken.value = refreshTokenValue - localStorage.setItem('access_token', accessTokenValue) - localStorage.setItem('refresh_token', refreshTokenValue) + function setTokens(authUser: AuthUser) { + accessToken.value = authUser.access_token + refreshToken.value = authUser.refresh_token + + if (authUser.profile) { + user.value = { + id: authUser.profile.sub || '', + userName: authUser.profile.preferred_username || authUser.profile.name || '', + email: authUser.profile.email || '', + tenantId: authUser.profile.tenant_id || '1', + roles: Array.isArray(authUser.profile.role) ? authUser.profile.role : (authUser.profile.role ? [authUser.profile.role] : []), + } + } + + localStorage.setItem('access_token', authUser.access_token) + localStorage.setItem('refresh_token', authUser.refresh_token) + localStorage.setItem('user_info', JSON.stringify(user.value)) } - function setUser(userData: User) { - user.value = userData - localStorage.setItem('user_info', JSON.stringify(userData)) + async function login() { + await authService.login() + } + + async function refresh() { + try { + const authUser = await authService.refresh() + setTokens(authUser) + } catch (error) { + console.error('Token refresh failed:', error) + clearAuth() + throw error + } + } + + async function logout() { + await authService.logout() + clearAuth() } function clearAuth() { - accessToken.value = null - refreshToken.value = null + accessToken.value = '' + refreshToken.value = '' user.value = null localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') localStorage.removeItem('user_info') } - function loadFromStorage() { + async function loadFromStorage() { const storedToken = localStorage.getItem('access_token') const storedRefreshToken = localStorage.getItem('refresh_token') const storedUser = localStorage.getItem('user_info') @@ -49,7 +76,16 @@ export const useAuthStore = defineStore('auth', () => { refreshToken.value = storedRefreshToken } if (storedUser) { - user.value = JSON.parse(storedUser) + try { + user.value = JSON.parse(storedUser) + } catch { + user.value = null + } + } + + const authUser = await authService.getUser() + if (authUser && !authUser.expired) { + setTokens(authUser) } } @@ -58,8 +94,10 @@ export const useAuthStore = defineStore('auth', () => { refreshToken, user, isAuthenticated, + login, + refresh, + logout, setTokens, - setUser, clearAuth, loadFromStorage, } diff --git a/src/Fengling.Console.Web/src/views/Audit/AccessLog.vue b/src/Fengling.Console.Web/src/views/Audit/AccessLog.vue new file mode 100644 index 0000000..8dabfbe --- /dev/null +++ b/src/Fengling.Console.Web/src/views/Audit/AccessLog.vue @@ -0,0 +1,346 @@ + + + + + diff --git a/src/Fengling.Console.Web/src/views/Audit/AuditLog.vue b/src/Fengling.Console.Web/src/views/Audit/AuditLog.vue new file mode 100644 index 0000000..f810d2c --- /dev/null +++ b/src/Fengling.Console.Web/src/views/Audit/AuditLog.vue @@ -0,0 +1,377 @@ + + + + + diff --git a/src/Fengling.Console.Web/src/views/Auth/Callback.vue b/src/Fengling.Console.Web/src/views/Auth/Callback.vue index 7b9071a..b469b93 100644 --- a/src/Fengling.Console.Web/src/views/Auth/Callback.vue +++ b/src/Fengling.Console.Web/src/views/Auth/Callback.vue @@ -1,32 +1,47 @@ + + diff --git a/src/Fengling.Console.Web/src/views/Gateway/Dashboard.vue b/src/Fengling.Console.Web/src/views/Gateway/Dashboard.vue index 592d80d..8fbf011 100644 --- a/src/Fengling.Console.Web/src/views/Gateway/Dashboard.vue +++ b/src/Fengling.Console.Web/src/views/Gateway/Dashboard.vue @@ -1,7 +1,10 @@ + diff --git a/src/Fengling.Console.Web/src/views/OAuth/ClientList.vue b/src/Fengling.Console.Web/src/views/OAuth/ClientList.vue index 2d417cc..7bd32d7 100644 --- a/src/Fengling.Console.Web/src/views/OAuth/ClientList.vue +++ b/src/Fengling.Console.Web/src/views/OAuth/ClientList.vue @@ -12,20 +12,59 @@ - - - - - + + + + + + + + + + + + + + + 查询 + 重置 + + + + + + + + + + + + + + - + + + + @@ -36,38 +75,75 @@ v-model:page-size="pageSize" :total="total" @current-change="fetchClients" - layout="total, prev, pager, next" + @size-change="fetchClients" + layout="total, sizes, prev, pager, next, jumper" style="margin-top: 20px" /> - - + + - + - + - - + + - - + + + + + - - - - + + 授权码模式 + 密码模式 + 客户端凭证模式 + 刷新令牌 + 简化模式 + + + + + + + + 机密客户端(有Secret) + 公开客户端(无Secret) + + + + + + + - - - - + + + + + + + + + + + + diff --git a/src/Fengling.Console.Web/src/views/Users/TenantList.vue b/src/Fengling.Console.Web/src/views/Users/TenantList.vue new file mode 100644 index 0000000..1273115 --- /dev/null +++ b/src/Fengling.Console.Web/src/views/Users/TenantList.vue @@ -0,0 +1,575 @@ + + + + + diff --git a/src/Fengling.Console.Web/src/views/Users/UserList.vue b/src/Fengling.Console.Web/src/views/Users/UserList.vue index 310d805..8674a84 100644 --- a/src/Fengling.Console.Web/src/views/Users/UserList.vue +++ b/src/Fengling.Console.Web/src/views/Users/UserList.vue @@ -5,18 +5,39 @@ - - - - - + + + + + + + + + + + + 查询 + 重置 + + + + + + + + + - + @@ -28,6 +49,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -74,7 +476,13 @@ onMounted(() => { padding: 20px; } -.header-content h2 { +.header-content { + display: flex; + justify-content: space-between; + align-items: center; +} + +h2 { margin: 0; font-size: 18px; color: #303133; diff --git a/src/Fengling.Console.Web/tsconfig.app.json b/src/Fengling.Console.Web/tsconfig.app.json index 8d16e42..44b0bf6 100644 --- a/src/Fengling.Console.Web/tsconfig.app.json +++ b/src/Fengling.Console.Web/tsconfig.app.json @@ -3,6 +3,10 @@ "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, /* Linting */ "strict": true, diff --git a/src/Fengling.Console.Web/vite.config.ts b/src/Fengling.Console.Web/vite.config.ts index 0eb9a4f..5c4a516 100644 --- a/src/Fengling.Console.Web/vite.config.ts +++ b/src/Fengling.Console.Web/vite.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import path from 'path' -export default defineConfig(({ mode }) => ({ +export default defineConfig(() => ({ plugins: [vue()], resolve: { alias: { @@ -12,16 +12,18 @@ export default defineConfig(({ mode }) => ({ server: { port: 5173, proxy: { - '/api/auth': { - target: 'http://localhost:5000', + '/.well-known': { + target: 'http://localhost:5132', changeOrigin: true, - rewrite: (path) => path.replace(/^\/api\/auth/, '') }, - '/api/gateway': { - target: 'http://localhost:5001', + '/connect': { + target: 'http://localhost:5132', changeOrigin: true, - rewrite: (path) => path.replace(/^\/api\/gateway/, '/api') - } + }, + '/api': { + target: 'http://localhost:5132', + changeOrigin: true, + }, } } })) diff --git a/test/Fengling.AuthService.Tests/AccountIntegrationTests.cs b/test/Fengling.AuthService.Tests/AccountIntegrationTests.cs new file mode 100644 index 0000000..001df92 --- /dev/null +++ b/test/Fengling.AuthService.Tests/AccountIntegrationTests.cs @@ -0,0 +1,108 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using FluentAssertions; +using Xunit; +using System.Net; +using System.Net.Http.Json; +using Fengling.AuthService.ViewModels; + +namespace Fengling.AuthService.Tests; + +public class AccountIntegrationTests : IClassFixture> +{ + private readonly WebApplicationFactory _factory; + private readonly HttpClient _client; + + public AccountIntegrationTests(WebApplicationFactory factory) + { + _factory = factory.WithWebHostBuilder(builder => + { + builder.UseEnvironment("Testing"); + }); + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + [Fact] + public async Task Login_Get_ReturnsLoginPage() + { + var response = await _client.GetAsync("/account/login"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("欢迎回来"); + content.Should().Contain("登录到 Fengling Auth"); + content.Should().Contain("用户名"); + content.Should().Contain("密码"); + } + + [Fact] + public async Task Login_Get_WithReturnUrl_ReturnsLoginPageWithReturnUrl() + { + var response = await _client.GetAsync("/account/login?returnUrl=/dashboard"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("value=\"/dashboard\""); + } + + [Fact] + public async Task Register_Get_ReturnsRegisterPage() + { + var response = await _client.GetAsync("/account/register"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("创建账号"); + content.Should().Contain("加入 Fengling Auth"); + content.Should().Contain("用户名"); + content.Should().Contain("邮箱"); + content.Should().Contain("密码"); + content.Should().Contain("确认密码"); + } + + [Fact] + public async Task Register_Get_WithReturnUrl_ReturnsRegisterPageWithReturnUrl() + { + var response = await _client.GetAsync("/account/register?returnUrl=/dashboard"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("value=\"/dashboard\""); + } + + [Fact] + public async Task Dashboard_Index_ReturnsLoginPageWhenNotAuthenticated() + { + var response = await _client.GetAsync("/dashboard"); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + var location = response.Headers.Location.ToString(); + location.Should().StartWith("/account/login"); + location.Should().Contain("dashboard"); + } + + [Fact] + public async Task Dashboard_Profile_ReturnsLoginPageWhenNotAuthenticated() + { + var response = await _client.GetAsync("/dashboard/profile"); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + var location = response.Headers.Location.ToString(); + location.Should().StartWith("/account/login"); + location.Should().Contain("dashboard%2Fprofile"); + } + + [Fact] + public async Task Dashboard_Settings_ReturnsLoginPageWhenNotAuthenticated() + { + var response = await _client.GetAsync("/dashboard/settings"); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + var location = response.Headers.Location.ToString(); + location.Should().StartWith("/account/login"); + location.Should().Contain("dashboard%2Fsettings"); + } +} diff --git a/test/Fengling.AuthService.Tests/Fengling.AuthService.Tests.csproj b/test/Fengling.AuthService.Tests/Fengling.AuthService.Tests.csproj new file mode 100644 index 0000000..c1cc282 --- /dev/null +++ b/test/Fengling.AuthService.Tests/Fengling.AuthService.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + +