跳到主要内容
版本:3.0.0

自定义认证鉴权实现指南

SSOJWTESS 三种内置实现都不适合你的场景时,可以通过实现 Aegis 暴露的几个接口,搭一套自己的认证鉴权链路。本指南按「从零接到端到端跑通」的顺序,说明每个扩展点的职责、实现要点和组合方式。

本指南只覆盖「认证 + 会话 + 授权」三个核心扩展点。权限缓存(IResourcePermissionManager)的替换属于部署形态调整,不在本指南范围,参考 Redis 用户会话 的接入方式即可。

什么时候需要自己实现

内置三种实现各有定位:

内置实现适合场景局限
Aegis.Authorization.Jwt本地账号系统、单体应用、快速原型Logout 未实现,Token 密钥来自登录参数
Aegis.Authorization.SSO对接中心化 SSO 平台只做认证,不做资源权限
Aegis.Authorization.ESSESS 企业平台、需要客凭访问依赖 ESS 平台,与具体权限模型强绑定

下面这些场景需要自己实现:

  • 自建账号系统(数据库存账号密码、需要密码哈希校验)
  • 对接非 SSO/ESS 的第三方认证(OAuth 提供商、LDAP、企业微信、钉钉)
  • 需要资源权限但不使用 ESS(自建权限表、RBAC 角色、ABAC 属性)
  • 需要登录过程做额外操作(审计、双因子、风控、登录日志)

三个核心扩展点的关系

自定义实现围绕三个接口展开,它们的职责完全不同:

扩展点接口职责是否必需
认证逻辑IAuthenticationManager登录、登出、Token 校验至少实现一个
会话存储IUserManager保存和读取登录会话不实现则用默认内存版
权限来源IAuthorizationManager返回资源权限列表需要权限控制时实现

IUserManager 的默认实现 DefaultUserManager 是内存版,单机调试可用。多实例部署需要替换为 Redis 版(参考 Redis 用户会话),或自己实现。

自定义认证:IAuthenticationManager

接口契约

public interface IAuthenticationManager
{
Task<CustomUser> Login(LoginInfo loginInfo);
Task Logout(string token);
Task<CustomUser?> CheckToken(string token);
}

三个方法的职责:

方法调用时机成功返回失败处理
Login调用 /api/Auth/Login写入会话后的 CustomUserAuthenticationException
Logout调用 /api/Auth/LogoutTask(无返回值)内部吞掉异常即可
CheckToken每次带 Token 的请求有效则返回 CustomUser,无效返回 null不要抛异常,由 TokenHandler 决定是否拦截

CheckToken 返回 null 不等于「请求被拒绝」。TokenHandler 会根据当前端点是否 AllowAnonymous 决定:匿名端点静默忽略,需认证端点抛 AuthenticationException 转 401。所以 CheckToken 内部不要自己抛认证异常,只返回 null 即可。

完整实现示例:对接外部 SSO 平台

下面是一个「对接自建 SSO 平台」的完整实现,覆盖登录、登出、Token 校验三个方法。模式与内置 Aegis.Authorization.SSO 一致:登录时调 SSO 拿 Token,后续请求先查本地会话、未命中时回源 SSO 拉用户。

示例里的 ExternalSsoClientSsoUserModelISsoOptions 等业务类型需要你自己定义,Aegis 不提供。ExternalSsoClient 是封装 SSO 平台 HTTP 调用的客户端(登录验证、用户信息查询、登出),按 SSO 平台的 API 自行实现。

第一步:定义用户模型

让用户模型实现 IUserIdentifiable,这样 DefaultUserManager.AddUser 会自动填充 UserId/UserCode/UserName 三个字段。

using Aegis.Core.Authentication.Models;

public sealed class SsoUserModel : IUserIdentifiable
{
public string UserId { get; set; }
public string UserCode { get; set; }
public string UserName { get; set; }
public string Email { get; set; }
public string Department { get; set; }
}

第二步:实现 IAuthenticationManager

using System;
using System.Threading.Tasks;
using Aegis.Core.Authentication;
using Aegis.Core.Authentication.Exceptions;
using Aegis.Core.Authentication.Models;

public sealed class ExternalSsoAuthenticationManager : IAuthenticationManager
{
private readonly ExternalSsoClient _ssoClient;
private readonly IUserManager _userManager;

public ExternalSsoAuthenticationManager(ExternalSsoClient ssoClient, IUserManager userManager)
{
_ssoClient = ssoClient;
_userManager = userManager;
}

public async Task<CustomUser> Login(LoginInfo loginInfo)
{
// 1. 调 SSO 平台校验账号密码,换取 SSO Token
// LoginInfo.UserIdentity 是登录账号,Password 是明文密码
SsoUserModel ssoUser;
string ssoToken;
try
{
ssoToken = await _ssoClient.ValidateCredentialsAsync(
loginInfo.UserIdentity, loginInfo.Password);
ssoUser = await _ssoClient.GetUserAsync(ssoToken);
}
catch (Exception ex)
{
// SSO 服务异常(网络、超时、HTTP 5xx)包装成认证失败
throw new AuthenticationException("SSO 服务调用失败", ex);
}

if (ssoUser == null || string.IsNullOrEmpty(ssoToken))
throw new AuthenticationException("SSO 鉴权未通过");

// 2. 用 SSO Token 作为本地会话的 Token
// 好处:后续回源 SSO 时直接用这个 Token,不需要额外维护映射
var expiredAt = DateTime.Now.AddMinutes(loginInfo.ExpiresMinutes);

// 3. 写入本地会话
// userModel 传 SsoUserModel(实现了 IUserIdentifiable),DefaultUserManager 会自动填充 UserId/UserCode/UserName
// identity 用 ssoUser.UserId,避免同一个用户多端登录时产生多条会话
return _userManager.AddUser(ssoToken, ssoUser.UserId, ssoUser, expiredAt);
}

public async Task Logout(string token)
{
// 1. 先调 SSO 平台登出,让 SSO 侧的 Token 也失效
try
{
await _ssoClient.RevokeTokenAsync(token);
}
catch
{
// SSO 登出失败不应阻塞本地登出,吞掉异常即可
// 真实场景建议记日志
}

// 2. 清理本地会话
var user = _userManager.GetUser(token);
if (user != null)
_userManager.RemoveUser(user.UserIdentity);
}

public async Task<CustomUser?> CheckToken(string token)
{
// 1. 先查本地会话,命中且未过期直接返回
var user = _userManager.GetUser(token);
if (user != null)
{
if (user.ExpireAt < DateTime.Now)
{
_userManager.RemoveUser(user.UserIdentity);
user = null;
}
else
{
return user;
}
}

// 2. 本地未命中时回源 SSO 拉用户(避免每次请求都打 SSO)
// 这是内置 SsoAuthenticationManager 的核心模式
SsoUserModel ssoUser;
try
{
ssoUser = await _ssoClient.GetUserAsync(token);
}
catch
{
// SSO 服务异常时返回 null,让 TokenHandler 决定是否拦截
// 不要抛异常,否则会把网络错误误判为「Token 无效」
return null;
}

if (ssoUser == null)
return null;

// 3. 拉到用户后回填本地会话,后续请求直接命中缓存
var expiredAt = DateTime.Now.AddDays(7); // 按业务需要调整
return _userManager.AddUser(token, ssoUser.UserId, ssoUser, expiredAt);
}
}

第三步:注册到 DI

// SSO 平台客户端(按 SSO 的认证配置初始化)
services.AddSingleton<ExternalSsoClient>(_ => new ExternalSsoClient(
ssoBaseUrl: Configuration["ExternalSso:BaseUrl"],
appId: Configuration["ExternalSso:AppId"],
appSecret: Configuration["ExternalSso:AppSecret"]));

// 替换默认的 IAuthenticationManager
services.AddSingleton<IAuthenticationManager, ExternalSsoAuthenticationManager>();

// IUserManager 用默认实现即可(单机);多实例参考 Redis 用户会话文档
// services.AddRedisUserManager<AegisRedisSource>();

对应的最小 appsettings.json 配置:

{
"Auth": {
"EnableAuthentication": true
},
"ExternalSso": {
"BaseUrl": "https://sso.example.com",
"AppId": "your-app-id",
"AppSecret": "your-app-secret"
}
}

关键设计点

设计点推荐做法反例
Login 失败AuthenticationException返回 null(基础认证不会处理)
CheckToken 失败返回 null抛异常(TokenHandler 会把它当系统错误)
本地会话未命中回源 SSO 拉用户并回填会话每次请求都打 SSO(性能差,SSO 一挂全站不可用)
SSO 服务异常CheckToken 里吞掉异常返回 null抛异常(会把 SSO 网络故障误判为 Token 无效)
Token 来源直接用 SSO 返回的 Token 作为本地会话 Token自己再生成一套 Token(多一层映射,回源时不方便)
Logout 失败吞掉异常,继续清本地会话直接抛(用户永远登出不了)
userModel 类型实现 IUserIdentifiableobjectdynamic(字段不会被自动填充)

自定义会话存储:IUserManager

何时需要自定义

默认的 DefaultUserManager 是内存版,适合单机调试。下面这些场景需要替换:

场景选择
多实例部署Redis 用户会话RedisUserManager<T>
ESS 平台EssRedisUserManager<T>(键策略贴近 ESS 流程)
数据库持久化自己实现 IUserManager
自定义键策略(按租户、设备)自己实现 IUserManager

接口契约

public interface IUserManager
{
CustomUser GetUser(string identity);
CustomUser AddUser(string token, string identity, object userModel, DateTime expiredAt);
void UpdateUser(CustomUser user);
bool RemoveUser(string identity);
}

注意接口里 identity 参数的语义。基础认证默认把 token 作为 identity 传入(参见 RedisUserManager.GetUser(string token) 的实现)。如果你要改成「按用户 ID 管理会话」,需要同时改写 IAuthenticationManager 里的调用方式。

实现示例:数据库会话

示例里的 ISessionStore 是业务侧自定义的存储抽象(Redis、数据库、内存都可以),Aegis 不提供。

using System;
using Aegis.Core.Authentication;
using Aegis.Core.Authentication.Models;

public sealed class DbUserManager : IUserManager
{
private readonly ISessionStore _store;

public DbUserManager(ISessionStore store)
{
_store = store;
}

public CustomUser GetUser(string identity)
{
// identity 默认是 token
return _store.Get<CustomUser>(identity);
}

public CustomUser AddUser(string token, string identity, object userModel, DateTime expiredAt)
{
var user = new CustomUser
{
Token = token,
UserIdentity = identity,
UserInfo = userModel,
ExpireAt = expiredAt
};

// 如果 userModel 实现了 IUserIdentifiable,手动提取字段
// (DefaultUserManager 会自动做这件事,自己实现时不要忘)
if (userModel is IUserIdentifiable identifiable)
{
user.UserId = identifiable.UserId;
user.UserCode = identifiable.UserCode;
user.UserName = identifiable.UserName;
}

_store.Set(token, user, expiredAt);
return user;
}

public void UpdateUser(CustomUser user)
{
// 用完整对象覆盖,用于刷新 Token 后更新 RefreshToken 或 ExtendInfo
_store.Set(user.Token, user, user.ExpireAt);
}

public bool RemoveUser(string identity)
{
return _store.Remove(identity);
}
}

关键设计点

  • 过期处理AddUser 接收 expiredAt,存储层应当设置对应的 TTL 或定时清理。DefaultResourcePermissionManager 不主动清理过期会话,依赖 IAuthenticationManager.CheckToken 里的检查。
  • 并发AddUserUpdateUser 都可能被并发调用(同一个用户连续登录两次)。存储层应当支持原子写入,或自己加锁。
  • identity 的语义:如果改成按用户 ID 而不是 token 存储,登录时一个用户多个 Token 会互相覆盖,需要自己设计多端登录策略。

自定义权限来源:IAuthorizationManager

接口契约

public interface IAuthorizationManager
{
Task<IEnumerable<ResourcePermission>> GetResourcePermissions(string token);
}

只有一个方法:按 token 返回该 Token 能访问的资源权限列表。ApiAuthorize 在权限缓存未命中时调用它。

实现示例:数据库 RBAC 权限

示例里的 IPermissionRepository 是业务侧自定义的权限查询抽象,Aegis 不提供。返回的 resources 集合按你权限表的结构自行映射。

using System.Collections.Generic;
using System.Threading.Tasks;
using Aegis.Core.Authorization;

public sealed class DbAuthorizationManager : IAuthorizationManager
{
private readonly IPermissionRepository _permRepo;

public DbAuthorizationManager(IPermissionRepository permRepo)
{
_permRepo = permRepo;
}

public async Task<IEnumerable<ResourcePermission>> GetResourcePermissions(string token)
{
// 1. 通过 token 找到当前用户(依赖 IUserManager 的会话)
// 如果 IAuthenticationManager 已经写了 CurrentUser.Value,也可以直接读
var user = CurrentUser.Value;
if (user == null)
return new List<ResourcePermission>();

// 2. 从数据库查该用户能访问的资源列表
var resources = await _permRepo.GetAllowedResources(user.UserId);

// 3. 转换成 ResourcePermission 列表
// Uri 和 RequestMethod 必须设置,ApiAuthorize 用这两个字段做比对
// RequestPath 字段不被读取,可以不设置
var permissions = new List<ResourcePermission>();
foreach (var res in resources)
{
permissions.Add(new ResourcePermission
{
Uri = res.Path, // 如 "/api/orders"
RequestMethod = res.Method, // 如 "GET"
CanAccess = true
});
}

return permissions;
}
}

注册到 DI

services.AddSingleton<IAuthorizationManager, DbAuthorizationManager>();

关键设计点

设计点说明
Uri 与请求路径必须一致ApiAuthorize 按 `[Method]
CanAccess = false 也会被缓存不要在权限列表里塞「无权限」的记录,只返回允许访问的
缓存生效期间不会重新调用默认 20 分钟缓存。权限变更后需要主动清缓存或重启
不要假设 CurrentUser.Value 一定有值客凭请求(带 ClientAuthorization 头)跳过用户检查,CurrentUser.Value 可能是 null

完整组合:端到端接入

把三个扩展点串起来,从「新建项目」到「接口被保护」的完整路径:

Component.deps.json 配置

不需要注册自定义实现的组件名,只要 AuthenticationAuthorization 在列表里,自定义实现通过 DI 注册后会自动覆盖默认实现:

{
"Components": {
"Services": [
"Authentication",
"Authorization"
],
"Middlewares": [
"Authentication",
"Authorization"
]
}
}

Startup.cs 注册

public void ConfigureServices(IServiceCollection services)
{
services.AddAegis(Configuration);

// 替换默认实现(在 AddAegis 之后调用)
services.AddSingleton<IAuthenticationManager, ExternalSsoAuthenticationManager>();
services.AddSingleton<IAuthorizationManager, DbAuthorizationManager>();

// IUserManager 用默认实现即可;多实例参考 Redis 用户会话文档
// services.AddRedisUserManager<AegisRedisSource>();

// 业务依赖
// SSO 平台客户端(认证用)
services.AddSingleton<ExternalSsoClient>(_ => new ExternalSsoClient(
Configuration["ExternalSso:BaseUrl"],
Configuration["ExternalSso:AppId"],
Configuration["ExternalSso:AppSecret"]));
// 权限仓储(授权用,按你的权限表实现)
services.AddSingleton<IPermissionRepository, DbPermissionRepository>();
}

appsettings.json 配置

{
"Auth": {
"EnableAuthentication": true,
"EnableAuthorization": true
}
}

验证清单

接入完成后,按以下顺序验证:

  • 不带 Token 访问 [ApiAuthorize] 接口返回 401
  • 调用 /api/Auth/Login 能返回 Token
  • 带 Token 访问 [ApiAuthorize] 接口能通过(前提是权限列表里有对应资源)
  • 带 Token 访问权限列表里没有的接口返回 403
  • 带 Token 访问 [AllowAnonymous] 接口能通过
  • 登出后再用原 Token 访问返回 401

与内置实现的组合

自定义实现不一定要全部自己写,可以和内置组件混用:

组合场景
内置 SsoAuthorize + 自定义 IAuthorizationManager用 SSO 做登录,但用自己的权限表
内置 JwtAuthorize + 自定义 IUserManager(Redis)用 JWT 做登录态,但会话存 Redis
自定义 IAuthenticationManager + 内置 EssAuthorize 的权限部分自己做登录,但用 ESS 的资源权限

混用时只要在 Startup 里按顺序注册即可,DI 会自动用最后注册的实现。

常见陷阱

CheckToken 抛异常导致 500

错误写法:

public Task<CustomUser?> CheckToken(string token)
{
var user = _userRepo.Get(token);
if (user == null)
throw new AuthenticationException("用户不存在"); // 错误
return Task.FromResult<CustomUser?>(user);
}

正确写法:返回 null,由 TokenHandler 决定是否拦截。抛异常会被 TokenHandler 捕获并返回 401,但如果是数据库连接异常等系统错误,会被误判为「Token 无效」,掩盖真实问题。

Login 返回 null 导致 500

Login 的返回类型是 Task<CustomUser>(非空)。返回 null 会在 AuthController.Login 里抛 NullReferenceException。登录失败必须抛 AuthenticationException

权限列表的 Uri 大小写不一致

ApiAuthorize{Method}|{Path} 严格比对。如果数据库里存的是 /api/Orders,请求路径是 /api/orders,比对失败,返回 403。建议在权限表里统一用小写存储,或在 GetResourcePermissions 里做一次归一化。

Logout 不清理会话

Logout 接口本身不做清理,需要自己调用 IUserManager.RemoveUser。如果只删除了数据库记录但没清理内存会话,Token 在过期前仍然可用。

进一步参考