feat: 网关管理模块 + RBAC权限认证 + 组织架构 + 审计日志
- 网关管理:GatewayEntity/Service/Controller CRUD + 测试连接;设备 GatewayId 外键关联 - JWT 认证:登录签发 Token(权限编码写入 Claims)、RequirePermission 权限过滤器、CurrentUser 上下文 - RBAC:用户/角色/权限实体与 CRUD,预定义 4 角色 + 6 权限种子(admin/admin123) - 组织架构:sys_org 固定层级树(公司/实验室/部门/班组白夜班),层级校验,用户挂 OrgId/岗位/技能标签 - 数据权限:角色 DataScope(全部/本组织及下级),用户列表按组织子树过滤 - 防锁死保护:禁止删/禁自己,保证至少一名活跃管理员,角色摘除 user:manage 前校验 - 审计日志:AuditRecorder 接入设备增删改/指令下发/登录登出/组织变更,AuditController 查询 - 设备指令下发按网关表取连接参数;设备列表支持 gatewayId/productId 筛选 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Model;
|
||||
using Model.Dto.System;
|
||||
using Model.Entity.System;
|
||||
using Model.Mapper;
|
||||
using ORM;
|
||||
using Service.Interface;
|
||||
using SqlSugar;
|
||||
|
||||
namespace Service.Implement
|
||||
{
|
||||
/// <summary>
|
||||
/// 认证授权 服务实现:登录校验 → 加载角色/权限 → 签发 JWT(权限编码写入 Claims)
|
||||
/// </summary>
|
||||
public class AuthService : IAuthService
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly ILogger<AuthService> _logger;
|
||||
|
||||
public AuthService(IConfiguration configuration, ICurrentUser currentUser, ILogger<AuthService> logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_currentUser = currentUser;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Result<LoginResultDto>> LoginAsync(LoginDto dto, string? ip)
|
||||
{
|
||||
if (dto == null || string.IsNullOrWhiteSpace(dto.UserName) || string.IsNullOrWhiteSpace(dto.Password))
|
||||
return Result<LoginResultDto>.Error("用户名和密码不能为空");
|
||||
|
||||
try
|
||||
{
|
||||
var user = await SqlSugarContext.DbContext.Queryable<UserEntity>()
|
||||
.Where(x => x.UserName == dto.UserName && x.IsDel == 0).FirstAsync();
|
||||
if (user == null)
|
||||
return Result<LoginResultDto>.Error("用户名或密码错误");
|
||||
if (user.IsEnabled == 0)
|
||||
return Result<LoginResultDto>.Error("账号已被禁用,请联系管理员");
|
||||
if (!PasswordHelper.Verify(dto.Password, user.PasswordHash))
|
||||
return Result<LoginResultDto>.Error("用户名或密码错误");
|
||||
|
||||
// 加载角色与权限
|
||||
var (roleIds, roleNames, permissionCodes, dataScope) = await LoadUserAuthAsync(user.Id);
|
||||
|
||||
// 签发 JWT
|
||||
var token = GenerateJwt(user, roleIds, roleNames, permissionCodes, dataScope);
|
||||
|
||||
// 更新最后登录时间
|
||||
await SqlSugarContext.DbContext.Updateable<UserEntity>()
|
||||
.SetColumns(x => x.LastLoginTime == DateTime.Now)
|
||||
.Where(x => x.Id == user.Id).ExecuteCommandAsync();
|
||||
|
||||
// 登录审计
|
||||
await WriteAuditAsync(user.Id, user.UserName, roleIds.FirstOrDefault(), roleNames.FirstOrDefault(),
|
||||
"Login", "User", user.Id, 0, null, null, ip, $"用户 {user.UserName} 登录系统");
|
||||
|
||||
var userDto = user.ToDto();
|
||||
userDto.RoleIds = roleIds.Select(r => r.ToString()).ToList();
|
||||
userDto.RoleNames = string.Join(",", roleNames);
|
||||
return Result<LoginResultDto>.Success(new LoginResultDto
|
||||
{
|
||||
Token = token,
|
||||
RefreshToken = token,
|
||||
User = userDto,
|
||||
Permissions = permissionCodes
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "登录异常");
|
||||
return Result<LoginResultDto>.Error("登录失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> LogoutAsync()
|
||||
{
|
||||
if (!_currentUser.IsAuthenticated)
|
||||
return Result.Success();
|
||||
try
|
||||
{
|
||||
await WriteAuditAsync(_currentUser.UserId, _currentUser.UserName,
|
||||
_currentUser.RoleIds.FirstOrDefault(), _currentUser.RoleNames.FirstOrDefault(),
|
||||
"Logout", "User", _currentUser.UserId, 0, null, null, null,
|
||||
$"用户 {_currentUser.UserName} 退出登录");
|
||||
return Result.Success();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "退出登录记录审计失败");
|
||||
return Result.Success(); // 退出本身不应失败
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<LoginResultDto>> GetMeAsync()
|
||||
{
|
||||
if (!_currentUser.IsAuthenticated)
|
||||
return Result<LoginResultDto>.Error("未登录或 Token 已失效");
|
||||
|
||||
try
|
||||
{
|
||||
var user = await SqlSugarContext.DbContext.Queryable<UserEntity>()
|
||||
.Where(x => x.Id == _currentUser.UserId && x.IsDel == 0).FirstAsync();
|
||||
if (user == null)
|
||||
return Result<LoginResultDto>.Error("用户不存在或已被删除");
|
||||
|
||||
var userDto = user.ToDto();
|
||||
userDto.RoleIds = _currentUser.RoleIds.Select(r => r.ToString()).ToList();
|
||||
userDto.RoleNames = string.Join(",", _currentUser.RoleNames);
|
||||
return Result<LoginResultDto>.Success(new LoginResultDto
|
||||
{
|
||||
Token = "",
|
||||
RefreshToken = "",
|
||||
User = userDto,
|
||||
Permissions = _currentUser.Permissions
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<LoginResultDto>.Error("获取当前用户信息失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> ChangePasswordAsync(ChangePasswordDto dto)
|
||||
{
|
||||
if (dto == null || string.IsNullOrWhiteSpace(dto.OldPassword) || string.IsNullOrWhiteSpace(dto.NewPassword))
|
||||
return Result.Error("原密码和新密码不能为空");
|
||||
if (dto.NewPassword.Length < 6)
|
||||
return Result.Error("新密码长度不能少于 6 位");
|
||||
if (!_currentUser.IsAuthenticated)
|
||||
return Result.Error("未登录或 Token 已失效");
|
||||
|
||||
try
|
||||
{
|
||||
var user = await SqlSugarContext.DbContext.Queryable<UserEntity>()
|
||||
.Where(x => x.Id == _currentUser.UserId && x.IsDel == 0).FirstAsync();
|
||||
if (user == null)
|
||||
return Result.Error("用户不存在或已被删除");
|
||||
if (!PasswordHelper.Verify(dto.OldPassword, user.PasswordHash))
|
||||
return Result.Error("原密码错误");
|
||||
|
||||
await SqlSugarContext.DbContext.Updateable<UserEntity>()
|
||||
.SetColumns(x => x.PasswordHash == PasswordHelper.Hash(dto.NewPassword))
|
||||
.Where(x => x.Id == user.Id).ExecuteCommandAsync();
|
||||
|
||||
await WriteAuditAsync(user.Id, user.UserName, _currentUser.RoleIds.FirstOrDefault(), _currentUser.RoleNames.FirstOrDefault(),
|
||||
"Update", "User", user.Id, 0, null, null, null, $"用户 {user.UserName} 修改了自己的密码");
|
||||
return Result.Success();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Error("修改密码失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载用户的角色Id/角色名/权限编码/数据范围(取所有角色的并集,数据范围取最宽)
|
||||
/// </summary>
|
||||
public static async Task<(List<long> roleIds, List<string> roleNames, List<string> permissions, byte dataScope)>
|
||||
LoadUserAuthAsync(long userId)
|
||||
{
|
||||
var db = SqlSugarContext.DbContext;
|
||||
var roleIds = await db.Queryable<UserRoleEntity>()
|
||||
.Where(x => x.UserId == userId)
|
||||
.Select(x => x.RoleId)
|
||||
.ToListAsync();
|
||||
|
||||
var roleList = new List<RoleEntity>();
|
||||
if (roleIds.Count > 0)
|
||||
roleList = await db.Queryable<RoleEntity>()
|
||||
.Where(x => roleIds.Contains(x.Id) && x.IsDel == 0).ToListAsync();
|
||||
|
||||
var permissionCodes = new List<string>();
|
||||
if (roleIds.Count > 0)
|
||||
{
|
||||
var permIds = await db.Queryable<RolePermissionEntity>()
|
||||
.Where(x => roleIds.Contains(x.RoleId))
|
||||
.Select(x => x.PermissionId)
|
||||
.ToListAsync();
|
||||
if (permIds.Count > 0)
|
||||
{
|
||||
var perms = await db.Queryable<PermissionEntity>()
|
||||
.Where(x => permIds.Contains(x.Id) && x.IsDel == 0).ToListAsync();
|
||||
permissionCodes = perms.Select(p => p.Code).Distinct().ToList();
|
||||
}
|
||||
}
|
||||
|
||||
// 数据范围取最宽:任一角色是"全部"即为全部
|
||||
var dataScope = roleList.Count > 0 ? roleList.Min(r => r.DataScope) : (byte)2;
|
||||
return (roleIds, roleList.Select(r => r.Name).ToList(), permissionCodes, dataScope);
|
||||
}
|
||||
|
||||
/// <summary>生成 JWT:把用户身份 + 角色 + 权限编码全部写入 Claims(授权过滤器免查库)</summary>
|
||||
private string GenerateJwt(UserEntity user, List<long> roleIds, List<string> roleNames, List<string> permissions, byte dataScope)
|
||||
{
|
||||
var jwt = _configuration.GetSection("Jwt");
|
||||
var secret = jwt["SecretKey"]!;
|
||||
var issuer = jwt["Issuer"] ?? "IOT-API";
|
||||
var audience = jwt["Audience"] ?? "IOT-Web";
|
||||
var expireMinutes = double.TryParse(jwt["ExpireMinutes"], out var m) ? m : 480;
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new(ClaimTypes.Name, user.UserName),
|
||||
new("roleIds", string.Join(",", roleIds)),
|
||||
new("roleNames", string.Join(",", roleNames)),
|
||||
new("permissions", string.Join(",", permissions)),
|
||||
new("dataScope", dataScope.ToString()),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N"))
|
||||
};
|
||||
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
|
||||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
var token = new JwtSecurityToken(issuer, audience, claims,
|
||||
expires: DateTime.Now.AddMinutes(expireMinutes), signingCredentials: creds);
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
/// <summary>写一条审计日志</summary>
|
||||
private static async Task WriteAuditAsync(long userId, string? userName, long? roleId, string? roleName,
|
||||
string operationType, string operationTarget, long targetId, long deviceId,
|
||||
string? oldValue, string? newValue, string? ip, string? description)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
await SqlSugarContext.DbContext.Insertable(new AuditLogEntity
|
||||
{
|
||||
UserId = userId,
|
||||
UserName = userName ?? "",
|
||||
RoleId = roleId ?? 0,
|
||||
RoleName = roleName ?? "",
|
||||
OperationType = operationType,
|
||||
OperationTarget = operationTarget,
|
||||
TargetId = targetId,
|
||||
DeviceId = deviceId,
|
||||
OldValue = oldValue,
|
||||
NewValue = newValue,
|
||||
Ip = ip,
|
||||
Description = description,
|
||||
OperateTime = now,
|
||||
CreateTime = now
|
||||
}).ExecuteCommandAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user