自定义认证鉴权实现指南
当 SSO、JWT、ESS 三种内置实现都不适合你的场景时,可以通过实现 Aegis 暴露的几个接口,搭一套自己的认证鉴权链路。本指南按「从零接到端到端跑通」的顺序,说明每个扩展点的职责、实现要点和组合方式。
本指南只覆盖「认证 + 会话 + 授权」三个核心扩展点。权限缓存(
IResourcePermissionManager)的替换属于部署形态调整,不在本指南范围,参考 Redis 用户会话 的接入方式即可。
什么时候需要自己实现
内置三种实现各有定位:
| 内置实现 | 适合场景 | 局限 |
|---|---|---|
Aegis.Authorization.Jwt | 本地账号系统、单体应用、快速原型 | Logout 未实现,Token 密钥来自登录参数 |
Aegis.Authorization.SSO | 对接中心化 SSO 平台 | 只做认证,不做资源权限 |
Aegis.Authorization.ESS | ESS 企业平台、需要客凭访问 | 依赖 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 | 写入会话后的 CustomUser | 抛 AuthenticationException |
Logout | 调用 /api/Auth/Logout | Task(无返回值) | 内部吞掉异常即可 |
CheckToken | 每次带 Token 的请求 | 有效则返回 CustomUser,无效返回 null | 不要抛异常,由 TokenHandler 决定是否拦截 |
CheckToken返回null不等于「请求被拒绝」。TokenHandler会根据当前端点是否AllowAnonymous决定:匿名端点静默忽略,需认证端点抛AuthenticationException转 401。所以CheckToken内部不要自己抛认证异常,只返回null即可。
完整实现示例:对接外部 SSO 平台
下面是一个「对接自建 SSO 平台」的完整实现,覆盖登录、登出、Token 校验三个方法。模式与内置 Aegis.Authorization.SSO 一致:登录时调 SSO 拿 Token,后续请求先查本地会话、未命中时回源 SSO 拉用户。
示例里的
ExternalSsoClient、SsoUserModel、ISsoOptions等业务类型需要你自己定义,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 类型 | 实现 IUserIdentifiable | 用 object 或 dynamic(字段不会被自动填充) |
自定义会话存储: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里的检查。 - 并发:
AddUser和UpdateUser都可能被并发调用(同一个用户连续登录两次)。存储层应当支持原子写入,或自己加锁。 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 配置
不需要注册自定义实现的组件名,只要 Authentication 和 Authorization 在列表里,自定义实现通过 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 在过期前仍然可用。