diff --git a/NewLife.Remoting.Extensions/Controllers/BaseController.cs b/NewLife.Remoting.Extensions/Controllers/BaseController.cs
index ea65df2..a91171a 100644
--- a/NewLife.Remoting.Extensions/Controllers/BaseController.cs
+++ b/NewLife.Remoting.Extensions/Controllers/BaseController.cs
@@ -1,4 +1,4 @@
-using System.Reflection;
+using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Controllers;
@@ -16,7 +16,11 @@ namespace NewLife.Remoting.Extensions;
/// <summary>业务接口控制器基类</summary>
/// <remarks>
-/// 提供统一的令牌解码验证架构
+/// 提供统一的令牌解码/鉴权与异常收敛,前置解析令牌,后置记录异常并回收池化的 DeviceContext。
+/// 将 JWT 写入 Jwt/DeviceContext(Code、ClientId、设备/在线),并在 HttpContext.Items 暴露供中间件/日志使用。
+/// 鉴权成功后映射 ClaimsPrincipal(ApiToken),可配合 [Authorize] 与策略(例如 DeviceRequired)。
+/// 支持方法/控制器/Endpoint 的 AllowAnonymous 跳过鉴权;错误统一返回 code/message/traceId,并屏蔽 SQL细节。
+/// 可覆写 OnAuthorize/OnWriteError/WriteLog;控制器为每请求实例,结合对象池保证性能与线程安全。
/// </remarks>
[ApiFilter]
[Route("[controller]")]
@@ -39,7 +43,7 @@ public abstract class BaseController : ControllerBase, IWebFilter, ILogProvider
private readonly ITokenService _tokenService;
private IDictionary<String, Object?>? _args;
//private static readonly Action<String>? _setip;
- private static readonly Pool<DeviceContext> _pool = new(64);
+ private static readonly Pool<DeviceContext> _pool = new(256);
#endregion
#region 构造
@@ -79,15 +83,26 @@ public abstract class BaseController : ControllerBase, IWebFilter, ILogProvider
ctx.UserHost = ip;
ctx["__ActionContext"] = context;
+ // 暴露到 HttpContext.Items,方便中间件/日志访问
+ HttpContext.Items[nameof(DeviceContext)] = ctx;
+
var token = ctx.Token = ApiFilterAttribute.GetToken(context.HttpContext);
try
{
- if (context.ActionDescriptor is ControllerActionDescriptor act && !act.MethodInfo.IsDefined(typeof(AllowAnonymousAttribute)))
+ if (context.ActionDescriptor is ControllerActionDescriptor act)
{
- // 匿名访问接口无需验证。例如星尘Node的SendCommand接口,并不使用Node令牌,而是使用App令牌
- var rs = OnAuthorize(token, ctx);
- if (!rs) throw new ApiException(ApiCode.Unauthorized, "认证失败");
+ var endpoint = context.HttpContext.GetEndpoint();
+ var allowAnon = act.MethodInfo.IsDefined(typeof(AllowAnonymousAttribute), true)
+ || act.ControllerTypeInfo.IsDefined(typeof(AllowAnonymousAttribute), true)
+ || endpoint?.Metadata?.GetMetadata<IAllowAnonymous>() != null;
+
+ if (!allowAnon)
+ {
+ // 匿名访问接口无需验证。例如星尘Node的SendCommand接口,并不使用Node令牌,而是使用App令牌
+ var rs = OnAuthorize(token, ctx);
+ if (!rs) throw new ApiException(ApiCode.Unauthorized, "认证失败");
+ }
}
}
catch (Exception ex)
@@ -144,6 +159,28 @@ public abstract class BaseController : ControllerBase, IWebFilter, ILogProvider
if (ex != null) throw ex;
+ // 将成功解析的身份映射为 ClaimsPrincipal,便于 [Authorize]/策略授权
+ if (jwt != null)
+ {
+ var claims = new List<Claim>(8);
+ if (!code.IsNullOrEmpty())
+ {
+ claims.Add(new Claim("code", code));
+ claims.Add(new Claim(ClaimTypes.NameIdentifier, code));
+ }
+ if (!context.ClientId.IsNullOrEmpty()) claims.Add(new Claim("client_id", context.ClientId));
+ //if (!context.UserHost.IsNullOrEmpty()) claims.Add(new Claim("ip", context.UserHost));
+ //if (context.Device != null)
+ //{
+ // var name = context.Device.Name;
+ // if (!name.IsNullOrEmpty()) claims.Add(new Claim(ClaimTypes.Name, name));
+ //}
+
+ var identity = new ClaimsIdentity(claims, "ApiToken");
+ var principal = new ClaimsPrincipal(identity);
+ HttpContext.User = principal;
+ }
+
return jwt != null;
}
@@ -155,6 +192,9 @@ public abstract class BaseController : ControllerBase, IWebFilter, ILogProvider
var ctx = Context;
if (ctx != null)
{
+ // 从 HttpContext.Items 移除暴露的 DeviceContext
+ HttpContext.Items.Remove(nameof(DeviceContext));
+
ctx.Clear();
_pool.Return(ctx);
Context = null!;
diff --git a/NewLife.Remoting.Extensions/RemotingExtensions.cs b/NewLife.Remoting.Extensions/RemotingExtensions.cs
index d424cd4..fdcf0e7 100644
--- a/NewLife.Remoting.Extensions/RemotingExtensions.cs
+++ b/NewLife.Remoting.Extensions/RemotingExtensions.cs
@@ -1,4 +1,5 @@
-using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebSockets;
using Microsoft.Extensions.DependencyInjection.Extensions;
using NewLife.Caching;
@@ -19,6 +20,7 @@ public static class RemotingExtensions
/// 注册TokenService令牌服务,提供令牌颁发与验证服务;
/// 注册密码提供者,用于通信过程中保护密钥,避免明文传输;
/// 注册缓存提供者的默认实现;
+ /// 注册授权策略 DeviceRequired(RequireClaim("code")),便于使用 [Authorize(Policy = "DeviceRequired")]保护接口;
/// </remarks>
/// <param name="services"></param>
/// <param name="setting"></param>
@@ -49,6 +51,14 @@ public static class RemotingExtensions
// 注册缓存提供者,必须有默认实现
services.TryAddSingleton<ICacheProvider, CacheProvider>();
+ // 授权策略:DeviceRequired => 要求存在 code 声明(由 BaseController 映射 ClaimsPrincipal 提供)
+ services.AddAuthorization();
+ services.PostConfigure<AuthorizationOptions>(options =>
+ {
+ if (options.GetPolicy("DeviceRequired") == null)
+ options.AddPolicy("DeviceRequired", policy => policy.RequireClaim("code"));
+ });
+
// 添加模型绑定器
//var binderProvider = new ServiceModelBinderProvider();
services.Configure<MvcOptions>(mvcOptions =>
diff --git a/NewLife.Remoting/ApiServer.cs b/NewLife.Remoting/ApiServer.cs
index 5f5e641..c693894 100644
--- a/NewLife.Remoting/ApiServer.cs
+++ b/NewLife.Remoting/ApiServer.cs
@@ -176,7 +176,7 @@ public class ApiServer : ApiHost, IServer, IServiceProvider
/// <summary>开始服务</summary>
/// <remarks>
- /// 初始化 <see cref="Encoder"/> 与 <see cref="Handler"/>,创建并启动底层 <see cref="IApiServer"/>。
+ /// 初始化 <see cref="ApiHost.Encoder"/> 与 <see cref="Handler"/>,创建并启动底层 <see cref="IApiServer"/>。
/// 若 <see cref="StatPeriod"/> 大于 0,启动统计定时器输出处理统计与网络状态。
/// </remarks>
public virtual void Start()
diff --git a/NewLife.Remoting/IEncoder.cs b/NewLife.Remoting/IEncoder.cs
index 8ce427f..f7be29c 100644
--- a/NewLife.Remoting/IEncoder.cs
+++ b/NewLife.Remoting/IEncoder.cs
@@ -123,7 +123,7 @@ public abstract class EncoderBase
if (msg.Reply && msg.Error) message.Code = reader.ReadInt32();
// 参数或结果
- if (reader.FreeCapacity > 0)
+ if (reader.Available > 0)
{
var len = reader.ReadInt32();
if (len > 0) message.Data = msg.Payload.Slice(reader.Position, len);
diff --git a/Samples/IoTZero/Controllers/ThingController.cs b/Samples/IoTZero/Controllers/ThingController.cs
index b05565a..624806f 100644
--- a/Samples/IoTZero/Controllers/ThingController.cs
+++ b/Samples/IoTZero/Controllers/ThingController.cs
@@ -1,5 +1,6 @@
using IoT.Data;
using IoTZero.Services;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NewLife.IoT.Models;
using NewLife.IoT.ThingModels;
@@ -16,6 +17,7 @@ namespace IoTZero.Controllers;
/// <param name="thingService"></param>
[ApiFilter]
[ApiController]
+[Authorize(Policy = "DeviceRequired")]
[Route("[controller]")]
public class ThingController(IDeviceService deviceService, ThingService thingService, IServiceProvider serviceProvider) : BaseController(deviceService, null, serviceProvider)
{