Skip to content

认证与安全

零配置跑起来的服务并非安全白纸。登录连错五次账号就锁,密码写进日志之前已经打上了码。真正躲不过去的只有一件事:生产环境必须配 JWT 签名密钥,不配服务直接起不来。剩下的每一项都有默认值,大半还能在运行时用 SysConfig 覆盖部署期的 Options。

上线前哪些必须改、哪些能放着用默认值,交给部署指南的安全基线列清单。这里只讲这些机制本身怎么运作。

JWT 令牌

访问令牌图的是短平快:一个短命 JWT,签发之后不落库。真正需要长期看管的是刷新令牌。服务端只存它的哈希,支持轮换和吊销,这套逻辑在 Core/Security/ITokenProvider.cs 里。配置项都在 SmartAdmin:JwtAdminJwtOptions)下:

配置键默认说明
SmartAdmin:Jwt:SecretKeynull签名密钥,至少 32 字节
SmartAdmin:Jwt:IssuerSmartAdmin签发者(iss claim)
SmartAdmin:Jwt:ExpireMinutes120访问令牌有效期(分钟)
SmartAdmin:Jwt:RefreshExpireMinutes10080刷新令牌有效期(分钟,7 天)

这把签名密钥从哪来?三条路径任选一条,判定逻辑在 JwtKeyResolver.cs 里:

  • 配了 SecretKey(生产环境必须配这个):直接用。但长度不够 32 字节就报错,拒绝启动。密钥一弱就能被暴力破解,进而伪造超管令牌。
  • 没配 + 生产环境:同样拒绝启动。为什么不用自动生成的开发密钥先顶上?因为多副本部署时各签各的,会导致用户随机 401;而且密钥一泄露,就能伪造任意令牌。
  • 没配 + 开发环境:自动生成 64 字节密钥,存在 {ContentRoot}/data/dev-jwt.key。重启也不换,令牌不失效,控制台会提醒这是临时密钥。这个文件和 SQLite 数据库同住 data 目录,天然被 .gitignore 挡在外面。

生产必配签名密钥

这是安全基线,不是随口建议:生产环境不显式配置 SmartAdmin:Jwt:SecretKey,服务直接起不来。

验证参数上有几处特意调整过,和框架默认值不一样。改动都在 SmartAdminSetup.cs 里:

  • MapInboundClaims = false:保留 sub/sid/sadm/org 这些原始 claim 名,不被框架偷偷改名。
  • ValidateAudience = false:单体后台用不上 audience 校验。
  • ValidateLifetime = true:过期的令牌照样拒。
  • ClockSkew 收紧到 30 秒(默认是 5 分钟):贴合短命令牌的节奏。
  • NameClaimType = unique_nameUser.Identity.Name 取的就是登录账号。

访问令牌和刷新令牌各自能活多久,不是写进配置文件就一锤定音。签发时会先看 ISecurityPolicyProvider.GetSessionTtlAsync()。这个方法先查 SysConfig,查不到才回退到 Jwt 的默认值。

运行时配置键回退默认
sys.security.session.accessMinutesJwt:ExpireMinutes
sys.security.session.refreshMinutesJwt:RefreshExpireMinutes

验证码

CaptchaService 内置三种验证码生成器,都不依赖绘图库。配置在 SmartAdmin:Security:CaptchaAdminCaptchaOptions)下:

配置键默认说明
...:Captcha:Enabledfalse是否启用登录验证码
...:Captcha:Typecharchar(字符 SVG)/ path(描边字形)/ math(算术)

默认是关的。为什么敢关?零配置起步的 API,得直接就能登录。账号级的登录锁定已经挡住了暴力破解的主攻方向。验证码只是浏览器侧再加一道保险,接了前端的部署和生产环境按需自己打开。

运行时还能用 SysConfig 覆盖,改完立即生效,不用重启。sys.security.captcha.enabled 管要不要强制校验,sys.security.captcha.type 管发哪一种。缺配置时都回退到 Options 默认值。

验证码票据只用一次。明文进缓存,2 分钟 TTL。签发时拿 GUID v7 当票据 Id。校验时玩的是原子取删(逻辑在 CaptchaService.cs):

csharp
// 并发携同一 captchaId 时只有一个调用取到非空值,杜绝单张验证码放大成 N 次猜测
var stored = await cache.GetAndRemoveAsync<string>(CacheKeys.Captcha(captchaId!));
AdminException.ThrowIf(stored is null, ErrorCode.CaptchaExpired);   // 40002
AdminException.ThrowIf(!string.Equals(stored, code, StringComparison.OrdinalIgnoreCase),
    ErrorCode.CaptchaWrong);   // 40003

不管填对填错,这张票据用一次就作废。同一张验证码,想重放或者多试几次都不行。想换成图片、滑块或者行为验证码?自己注册一个 ICaptchaProvider 提前接管就行。

短信验证(二次验证与免密登录)

短信能干两件不相关的事:登录时加一道二次验证,或者干脆免密码、靠短信登录。两个开关互相独立,默认都关着。在配置中心「安全策略」页运行时切换,改完立即生效,不用重启:

运行时配置键Options 兜底默认功能
sys.security.mfa.enabled...:SmsOtp:MfaEnabledfalse短信二次验证:密码通过后再验一次短信码
sys.security.smsLogin.enabled...:SmsOtp:LoginEnabledfalse免密登录:手机号 + 短信验证码

二次验证怎么走?密码这一侧全部过关之后(锁定 → 验证码 → 账密 → 策略),AuthService.CheckSmsSecondFactorAsync 才会接手。它开一张缓存挑战票据,绑定的是用户 id,不是手机号,然后发码。前端收到的是 SmsCodeRequired(40009)这个信令,意思是「还差一步」,args 里带着 challengeId/phoneMask/倒计时这些参数。后半程走 POST /api/v1/auth/login/sms(以及 /resend)。/login 接口本身的请求/响应契约没有变。40009 只是个信令,不是失败,也不计入登录锁定的失败次数。

二次验证只对绑定了手机号的用户生效

开关打开了,没绑手机号的用户会不会登不进去?不会,照样凭密码直登。这是故意的,不是漏做。全局开关一旦打开,就绝不能把任何人锁在系统外面。种子超管本来就没有手机号,存量用户里没绑手机号的也大有人在。想让某个账号必须走二次验证?给它绑一个手机号就行,个人资料页或者用户管理里都能绑。

如果你想要更严格的语义,「没手机号就不让登」,覆写一步就够:

csharp
public sealed class StrictAuthService(
    IRepository<SysUser> users, IPasswordHasher hasher, ITokenProvider tokens,
    ISessionService sessions, ILogService logService, ILoginLockService loginLock,
    ICaptchaService captcha, ISecurityPolicyProvider policy, ISmsOtpService smsOtp)
    : AuthService(users, hasher, tokens, sessions, logService, loginLock, captcha, policy, smsOtp)
{
    protected override async Task CheckSmsSecondFactorAsync(SysUser user)
    {
        // 内核默认对无手机号用户直通;严格模式改为拒登
        if (await smsOtp.IsMfaEnabledAsync() && string.IsNullOrWhiteSpace(user.Phone))
            throw new AdminException(ErrorCode.AccountDisabled);
        await base.CheckSmsSecondFactorAsync(user);
    }
}

免密登录呢? POST /api/v1/auth/sms/send 发码,POST /api/v1/auth/sms/login 拿手机号加验证码换令牌。发码这一步会联动图形验证码的开关:验证码开着时,发码端点也一并受保护。防的是枚举:不管手机号是不存在、重复还是被停用,拿到的响应外形都和真实路径一模一样,连冷却都照做,只是从来不会真的发码。后续校验统一报 SmsCodeExpired,不告诉你到底是哪种情况。

有一个副作用得提前知道:手机号重复的用户会被免密登录静默排除。原因是解析逻辑要求手机号必须唯一命中一个启用用户。内核没有给 Phone 字段加唯一索引,存量数据可能本来就有重复。要用这个功能,请自己在录入侧保证手机号唯一。

防滥用这件事全交给服务端强制,前端管不了。配置在 SmartAdmin:Security:SmsOtpAdminSmsOtpOptions)下,这是部署期配置,只有两个开关是运行时键:

配置项默认说明
CodeLength6验证码位数(密码学随机)
TtlSeconds300码(及 MFA 挑战)有效期
ResendSeconds60同号重发冷却,二次验证与免密发码共享
MaxAttempts5错码次数上限,达到即作废该码
DailySendLimitPerPhone10同号每日发送上限(防短信轰炸/费用失控)

验证码的消费方式和图形验证码一个套路:原子取删,用一次就废,防重放。而且只存缓存,不建表,零 DDL。冷却时间和每日计数,默认内存缓存下按实例各算各的,和登录锁定一个道理。装上 Redis 包,就变成全局共享。

谁来真正发这条短信?内核只定义了一个 ISmsSender 接口,自己不接厂商。默认实现是 LoggingSmsSender,把验证码写进后端日志([SMS:mfa] … / [SMS:login] …),开发阶段够用,生产环境显然不能这么干。在 AddSmartAdmin() 之前注册一个真实厂商的实现就能接管。purpose 参数是 mfa 还是 login,映射到厂商那边的模板 id:

csharp
builder.Services.AddSingleton<ISmsSender, AliyunSmsSender>();   // 你的实现
builder.Services.AddSmartAdmin(builder.Configuration);          // TryAdd 让位

涉及的错误码汇总一下:SmsCodeRequired 40009(信令)、SmsCodeWrong 40010(args 带 attemptsLeft)、SmsCodeExpired 40011(缺失/过期/已消费/次数耗尽,刻意不可区分)、SmsLoginDisabled 40012;发送过频复用 TooManyRequests 40008。

可选 TOTP(Authenticator)

短信二因子之外,内核还带一套默认关闭的 TOTP:用户用手机认证器扫码绑定,登录时在密码后再输 6 位动态口令。它和 SmsOtp 互不替代,可以只开其一,也可以都开。这套 TOTP 面向的是通用后台加固,不是等保定级包。

运行时总闸跟图形验证码一个路数,配置中心「安全策略」里即时开关,一般不必改 appsettings:

默认说明
sys.security.totp.enabledfalse能力总闸;关时绑定 / 登录挑战 / 恢复码一律拒绝
sys.security.totp.requireForSuperAdminfalse超管是否必须第二因子
SmartAdmin:Security:Totp:Enabledfalse部署地板:true 时能力始终开(UI 关不掉)
SmartAdmin:Security:Totp:IssuerSmartAdmin认证器里显示的 issuer
SmartAdmin:Security:Totp:ChallengeTtlSeconds300登录挑战有效期(秒)
SmartAdmin:Security:Totp:ReauthWindowMinutes5高危写操作再次确认窗口(分钟)
SmartAdmin:Security:Totp:RecoveryCodeCount10每次绑定生成的恢复码个数

账号级还有一个 ForceTotp(用户管理里的「强制动态口令」):只对这个人要求登录必须完成 TOTP,与全局键正交。总闸没开时,强制开关不会单独生效。

自助绑定是默认产品路径。POST /api/v1/auth/mfa/bind/start 要账号 + 当前密码,返回 otpauth URI 与一次性种子;complete 校验 6 位码后落库,并一次性下发恢复码(服务端只存哈希)。前端入口在登录后的内置「账号安全」页(/personal/security),登录页默认不常驻绑定链接。管理员清除:POST /api/v1/sys/mfa/clear(通常要再认证)。邀请绑定、InitGrant、紧急授权不是产品路径,内核不提供这类接口。

登录侧三种信封码(与短信 40009 同级,都是「还差一步」或「必须先绑」):

含义前端怎么做
40018已绑定,要动态口令进 TOTP 步;POST /api/v1/auth/login/totp
40019动态口令错误可重试
40020被强制但未绑定Modal 引导去 /mfa/bind不要整页替换成永久入口
40022恢复码无效 / 已用换码或走管理员清 MFA

恢复码:POST /api/v1/auth/mfa/recovery(账号 + 密码 + 码)。成功后清 MFA、吊销会话,需重新绑定。同一恢复码不能再用。

高危写操作(改用户、清 MFA、部分角色/会话操作)可挂 [RequireReauth]:短时窗口内再验密码或 TOTP(POST /api/v1/auth/reauth)。前端包的请求层收到 40024 就弹统一的再认证弹窗,通过后把原请求重放一次。

内核已经用 [RequireReauth] 挂住了一批高危端点:用户与角色的写操作、系统配置写操作、强制下线、定时任务写操作、菜单写操作、回收站彻底删除、清除 MFA,这份清单本身也管理得到。GET /api/v1/sys/mfa/high-sensitivity 列出内核默认集(HighSensitivityPermissions.Default,只读,不接受管理页删除)加消费方追加项;POST 同路径追加一条自定义权限码,DELETE /api/v1/sys/mfa/high-sensitivity/{id} 删掉一条自定义项(默认集永远删不掉)。三个端点都挂 [RolePermission],写操作再叠 [RequireReauth]。把一条路由追加进这份清单,只是把它记进「高敏感」名录;要让消费方自己的写端点也要求再认证,还是得在那个端点上显式挂 [RequireReauth]

TOTP 种子走 ISecretProtector 信封加密,不是全库字段加密产品。配置键总表见仓库 docs/agents/security-optional-config.md

默认会话模型不变:登录 JSON 里同时给 access + refresh,前端可把 refresh 放 localStorage,body 刷新。打开 SmartAdmin:Security:Session:CookieMode(默认 false,部署级,改完要重启)之后:

  • refresh 只进 HttpOnly Cookie(smart_rt),响应体里 refresh 清空
  • 另发可读 CSRF Cookie(smart_csrf);写请求须带头 X-Smart-CSRF,与 Cookie 常量时间比对
  • 登录出参带 sessionMode: "cookie"csrfRequired: true
  • 前端包的请求层已 credentials: 'include' 并自动带 CSRF;access 仍放内存,靠 Cookie 静默刷新

业务 API 仍靠 Authorization: Bearer + 每请求 sid 活跃校验;Cookie 代替 access token。跨子域再配 Session:CookieDomain(常配 SameSite=None + HTTPS)。同源 Vite 反代本地联调一般不必设 Domain。

会话表还可配闲置 / 绝对寿命(默认 0 = 不额外限制):Session:IdleMinutesNormalIdleMinutesMfaAbsoluteHours。与 CookieMode 独立,按需收紧。

邮件通道

内核给邮件也备了一个抽象 IEmailSender,和 ISmsSender 一个路数。眼下没有任何内置功能真的在用它。这是先立好的通道,等以后邮件验证码登录、通知邮件这类功能落地时直接拿来用,不用再补一次可替换性设计。

配置在 SmartAdmin:EmailAdminEmailOptions)下,只看一个字段就决定用哪个实现:

Host选中的实现行为
空(默认)LoggingEmailSender把收件人/主题写进后端日志,不真正发信。开发期看得到内容,生产没用
非空SmtpEmailSenderBCL System.Net.Mail 直连 SMTP(STARTTLS,默认端口 587)

SmtpEmailSender 的天花板,就是 BCL 自带的 SmtpClient 能做到的那些:基础 SMTP 没问题,但撑不住 OAuth2 SMTP,也接不了云厂商自己的 API。要这些能力,自己实现一个 IEmailSender 就行,比如接 MailKit。在 AddSmartAdmin() 之前注册,就能接管。这条替换路径已经进了回归测试,不用担心以后升级内核会把它顶掉:

csharp
builder.Services.AddSingleton<IEmailSender, MailKitEmailSender>();  // 你的实现,TryAdd 让位
builder.Services.AddSmartAdmin(builder.Configuration);

登录锁定(防爆破)

账号被锁定的时候,就算密码输对了也进不去。原因是 LoginLockService 卡在登录最前面,比账密校验还靠前。配置在 SmartAdmin:Security:LoginLockAdminLoginLockOptions)下:

配置键运行时键默认说明
...:LoginLock:MaxFailCountsys.security.loginLock.maxFailCount5连续密码错误多少次后锁定;<=0 关闭
...:LoginLock:LockMinutessys.security.loginLock.lockMinutes10锁定时长,也是失败计数的滑动过期窗口

失败次数存在缓存里,原子自增并刷新 TTL。只要一直在错,锁定窗口就跟着往后顺延。一旦停手,过了 LockMinutes,计数自动过期解锁。但只有「密码错误」才计入失败次数。验证码填错、账号已经锁定、账号已停用,这些都不算。不然锁定窗口能被无限拉长,还可能误伤本来没做错事的人。这段逻辑在 AuthService.OnLoginFailedAsync 里。

账号规范化对齐数据库

锁定计数用的 key 要先规范化:去首尾空白,转小写,而且这一步认下的「同一个账号」不能比数据库自己认的还窄。数据库那边靠排序规则(MySQL 的 utf8mb4_0900_ai_ci / PAD SPACE)会把大小写、尾部空白的变体判成同一行;规范化函数要是没跟上,这些变体就会被拆成两个独立的计数器,攻击者靠着这点差异,就能绕开锁定一直猜下去。

举例:AdminADMINadmin (尾部多一个空格)这三种写法,在这套排序规则下查到的都是同一行用户。规范化函数先把它们收敛成同一个缓存 key,三次错误尝试才会摞在同一个计数器上,不会因为写法不同被拆成三份,凭空多给攻击者两倍的可尝试次数。

怎么防止有人靠登录接口探测「这个账号存不存在」?不管是账号本身不存在,还是账号存在但密码错了,统一抛 ErrorCode.PasswordWrong,响应内容看不出区别。账号不存在的时候,后端还会陪跑一次等价代价的哈希计算,响应耗时也就看不出区别。两条能被拿来试探的通道,一起堵上。这段逻辑在 AuthService.ValidateUserAsync 里。

会话与强制下线

会话由 SessionService 管。数据库里那份是源头,缓存里那份纯粹是为了省掉热路径上的一次查询,两者不对等。刷新令牌只存 SHA-256 哈希,不存明文。时间统一走 UTC。登录的时候用 GUID v7 生成一个 sessionId,写进令牌的 sid claim 里。以后列举在线用户、强制下线,都靠这个锚点。

管理员在「在线用户」里点一下踢人,是不是要等令牌自然过期才生效?不用,强退是即时生效的。授权管道在每个请求里都会校验 sid 对应的会话是不是还活着,见请求管线第 ② 步。踢人这一下具体做了什么?

csharp
public virtual async Task RevokeAsync(string sessionId)
{
    // 标记会话行 RevokedAt
    // 标记刷新令牌 Status = Revoked
    await cache.RemoveAsync(CacheKeys.Session(sessionId));   // 缓存移除 → 下次校验查库得吊销 → 401
}

被踢的人手里那张 access token 就算还没过期,下一个请求照样 401。停用或者删除用户走的是 RevokeAllForUserAsync,一次性把这个人名下所有会话全部下线。

并发策略SmartAdmin:Security:SessionAdminSessionOptions):

配置键默认说明
...:Session:ModeMultiMulti(多端并存)/ Single(新登录踢旧)
...:Session:MaxConcurrent0最大并发会话数;>0 时超出按最早登录吊销最旧;0 不限
...:Session:CookieModefalsetrue 时 refresh 仅 HttpOnly Cookie + CSRF(见上节)
...:Session:CookieDomainnullCookie Domain;空 = 当前 host
...:Session:IdleMinutesNormal / IdleMinutesMfa0闲置超时(分);0 = 不启用
...:Session:AbsoluteHours0绝对最长小时;0 = 仅随 refresh

同一个人挤爆并发会话上限,该踢谁?这里用的是「先插入、再收敛」:新会话先插进数据库,收敛动作在那之后才做。这样一来,并发发生的两个登录都能看到对方那一行,各自算出的都是同一个「只保留最新 N 个」的答案,自然收敛到一致结果,不需要额外协调。为什么不用进程内锁?锁只在单个进程里有效。换成多副本部署,一个副本锁着,另一个副本照样能把同一个名额抢走,锁挡不住跨副本的并发。

刷新令牌用过一次之后再出现,说明什么?只有一种解释:重放。处理很干脆:直接吊销整个会话,哪怕因此把真正的用户也一起下线,安全优先。轮换这一步用的是条件更新,只有当前状态还是 Active 才会被置成 Used,顺带也当了一层并发保护。这段逻辑在 SessionService.RefreshAsync 里。

密码策略

密码复杂度由 SecurityPolicyProvider.GetPasswordPolicyAsync() 定,每一项都先读 SysConfig,读不到才回退默认值:

运行时配置键默认
sys.security.password.minLength8
sys.security.password.requireUppertrue
sys.security.password.requireLowertrue
sys.security.password.requireDigittrue
sys.security.password.requireSpecialfalse

不满足哪一条,就抛 ErrorCode.PasswordTooWeakargs 里把具体要求都带给前端去提示用户。密码本身用 PBKDF2 哈希存(Pbkdf2PasswordHasher)。

密码会不会过期? 复杂度要求之外,还有一条运行时可配的过期策略,默认是关的:sys.security.password.expireDays。种子默认 0,代表永不过期,大于 0 才启用。登录第 4 步会拿 SysUser.LastPasswordChangeTime 加上这个有效天数,和当前时间比一下,这段逻辑在 AuthService.CheckPasswordExpiryAsync 里。但过期不拦登录,只是把这个用户的 MustChangePassword 标记为真、落库,再随登录出参一起回传给前端,由前端强制跳到改密页。这和管理员主动重置密码,用的是同一个信号。自助改密成功之后,LastPasswordChangeTime 会刷新,标志会清掉,过期窗口从头重新计。

这里有个坑得注意:LastPasswordChangeTime 可能是 null,比如直接写进库里、没经过内核建号或改密的账号。真正判过期之前,系统会先给 null 锚点回填成当前时间,过期窗口从这次登录才开始算。不这么处理会怎样?开启策略的当天,一大批没有锚点的账号会被一起判定过期,集体卡在改密页上。自己实现 ISecurityPolicyProvider 的二开代码不受影响:GetPasswordExpireDaysAsync 带默认接口实现,返回 0,不实现这个成员也能编译通过,效果等同于关闭这条策略。

改密码的时候,允许改成上一个用过的密码吗? 默认允许,想禁掉就在配置中心「安全策略」里把 sys.security.password.historyCount 调成 N,系统会记住最近 N 个用过的口令。种子默认是 0,即不记。改密时拿新口令挨个比一下,撞上了就拒,抛 ErrorCode.PasswordReused(42025)。「当前口令」要单独判一次,为什么?历史表刚打开的时候是空的,光靠历史记录挡不住「改成当前正在用的这个」这种打擦边球的操作。IPasswordHistoryService 只存哈希,复用的是 SysUser.Password 那一套 IPasswordHasher。校验时逐条 Verify(明文,哈希)。每次写入之后,立刻把这个用户的历史记录裁到最新 N 条,多余的硬删掉,表不会随时间无限膨胀。

写入的位置有三处:自助改密(PersonalService),管理员建号,管理员重置密码(后两者都在 UserService)。后两处只记录、不校验:管理员指定的初始口令,不受「不能与历史重复」这条约束。这三处有一个共同的写法:都把 IPasswordHistoryService 声明成默认为 null 的可选构造参数

csharp
public class PersonalService(
    /* ...既有依赖... */
    IPasswordHistoryService? passwordHistory = null) : IPersonalService
{
    // 策略关闭或消费方未注入时,?. 直接短路成空操作,不抛也不查
    await (passwordHistory?.EnsureNotReusedAsync(userId, input.NewPassword) ?? Task.CompletedTask);
}

这么写是专门为可替换性让路的。参数带了默认值,继承 PersonalService/UserService 的二开子类,主构造器不用跟着改,旧的调用点照样编译通过,效果等同于「历史策略关闭」。反过来想,要是这里改成必需参数会怎样?内核往后每加一个可选的安全策略,所有下游子类的构造函数签名就得跟着改一遍。这个代价,谁都不想付。

新建用户或者重置密码,给的初始口令从哪来? 配置项是 SmartAdmin:Security:DefaultInitialPassword,默认是 null。这时候系统会按账号现生成一个密码学随机的强口令,不用写死的默认密码。为什么较真到这个地步?「随公开 NuGet 包分发一个固定默认口令」是一个已知的凭据弱点,谁都能翻源码找到它,等于给每个用这个内核的项目开了同一把后门钥匙。重置密码的时候,这个随机口令会原样返回给管理员,由管理员当场转达给用户。

首次启动的超管口令

配了 SmartAdmin:Seed:AdminPassword 就用配置里给的值;没配(默认情况)就随机生成一个,并且在启动日志里醒目打印一次。只有真正建号的那一次启动才会打印,后面再启动就不打了。打印一个已经失效的随机密码只会误导人,没有意义。

API Key 接入(机器端)

设备端、PDA、第三方系统调后台时没有「登录会话」这回事,它们拿一把预共享密钥。内核为此提供第二个认证 scheme,名字就叫 ApiKey,与 JWT 并存互不干扰:默认 scheme 仍是 Bearer,只有端点显式挂了 [ApiKey] 才走到它。

csharp
[ApiController]
[Route("api/v1/device")]
public class DeviceController : ControllerBase
{
    [HttpPost("heartbeat")]
    [ApiKey]                      // 只认 X-Api-Key,不认用户 JWT;无 key / 错 key → 401 + 40027
    public Result<bool> Heartbeat(HeartbeatInput input) => ...;

    [HttpGet("orders")]
    [ApiKey]
    [RolePermission]              // key 绑了用户才过得去:按该用户的角色与数据范围判
    public Result<PagedList<Order>> Orders([FromQuery] OrderQuery q) => ...;

    [HttpGet("status")]
    [ApiKey]
    [SkipEnvelope]                // 成功返回不包信封,响应体就是 dto 本身
    public DeviceStatus Status() => ...;
}

密钥从请求头取,头名默认 X-Api-Key,配置在 SmartAdmin:Security:ApiKey

json
{
  "SmartAdmin": {
    "Security": {
      "ApiKey": {
        "HeaderName": "X-Api-Key",
        "Keys": [
          { "Name": "pda", "Key": "<32 字节以上随机串,经环境变量注入>" },
          { "Name": "erp", "Key": "<...>", "UserId": 68860291608576 }
        ]
      }
    }
  }
}

Name 进操作日志与限流分区,也是主体的 unique_nameKey 是密钥明文,比较走恒定时间。UserId 可选:绑了用户,这把 key 调叠了 [RolePermission] 的端点时,权限码、数据范围、操作日志与该用户完全一致,不另造一套授权模型;不绑,只能调仅挂 [ApiKey] 的端点,叠了 [RolePermission] 的一律 403,数据范围按空范围(fail-closed)处理。机器主体没有会话,[RolePermission][ActiveSession] 对它跳过会话校验。同一端点想同时接受 JWT 与 key,直接写 [Authorize(AuthenticationSchemes = "Bearer,ApiKey")]

密钥来源是可替换的:默认实现 ConfigApiKeyValidator 读上面的配置节,要从数据库表或密钥管理服务取,在 AddSmartAdmin() 之前注册自己的 IApiKeyValidator 即整体替换(TryAdd)。校验通过返回 ApiKeyPrincipal(Name, UserId),不认识返回 null

[SkipEnvelope] 是独立的:对接第三方要求固定响应形状时挂上它,成功返回原样放出,OpenAPI 契约同步不加信封外壳。它只影响成功路径,业务异常仍走统一信封(200 + code),401 / 403 / 429 也不变。契约里 [ApiKey] 端点的 x-auth 标为 apikey

请求限流

限流按的是客户端 IP,固定窗口算法。带 API Key 头的请求另起一桶,按 key 的哈希分区、用 KeyPermitPerWindow 计数,与用户端的 IP 桶互不影响:同一台网关后面的一堆设备不分摊一个 IP 额度,一把 key 刷爆也不殃及同 IP 的人。靠内置的 IStartupFilter 自动挂上 UseRateLimiter,不用手动接中间件。配置在 SmartAdmin:Security:RateLimitAdminRateLimitOptions)下:

配置键运行时键默认说明
...:RateLimit:Enabledsys.security.rateLimit.enabledtrue部署期硬总开关;false 时无论 DB 配置都不限流
...:RateLimit:WindowSecondssys.security.rateLimit.windowSeconds60窗口长度(秒)
...:RateLimit:PermitPerWindowsys.security.rateLimit.permitPerWindow300全局:单 IP 每窗口请求数(挡洪泛)
...:RateLimit:AuthPermitPerWindowsys.security.rateLimit.authPermitPerWindow20认证端点(/api/v1/auth/*)更严一档,挡在线爆破
...:RateLimit:KeyPermitPerWindow(无,只在 Options 里配)600机器端:单把 API Key 每窗口请求数,与 IP 桶分开计数;<=0 不限

Enabled 是部署期的硬总开关,一旦是 true,实际生不生效、阈值多少,就交给 SysConfig 在运行时说了算。

反向代理后取的是代理 IP

上正式网关之前,记得先接 ForwardedHeaders 中间件解析 X-Forwarded-For。不接会怎样?同一个反向代理后面的所有客户端会被当成同一个 IP,共用一个限流分区。一个人把额度用完,所有人跟着被限流。

演示模式(只读展示)

对外演示站要的是谁都能点进去玩、谁也改不动数据。打开 SmartAdmin:DemoMode(默认 false)就行:

jsonc
// appsettings.json,或环境变量 SmartAdmin__DemoMode=true
{ "SmartAdmin": { "DemoMode": true } }

开关打开之后,内核才会注册一个全局授权过滤器 DemoModeFilter,按 HTTP 方法放行。GET/HEAD/OPTIONS 照常读。/api/v1/auth/*(登录、登出、刷新)也得放行,不然演示站连登录都进不去。剩下的 POST/PUT/PATCH/DELETE 一律回 HTTP 403,信封码 41002DemoModeReadOnly),前端认这个码弹一个只读提示。

它拦的是「写」这个动作本身,跟角色权限没关系。判断发生在授权阶段,只看请求方法,所以哪怕是超管账号进来,照样改不动任何数据。想单独放行某个写接口(比如演示站自己的留言反馈功能)?别指望这个总开关帮忙,在业务代码那一侧单独处理。

日志脱敏

操作日志默认把所有写操作都记下来,读操作和匿名端点除外。这里有个明显的风险:入参里可能带着明文密码,直接落库就是日志里躺着一份密码。所以入参在写库之前会先脱敏,这段逻辑在 SensitiveDataMasker.cs 里,由 OperationLogFilter 调用:

csharp
var paramJson = SensitiveDataMasker.Mask(context.ActionArguments, logging.OpLog.ParamMaxChars);

脱敏靠的是字段名,不看值:属性名里只要含 passwordpwdsecrettokencredentialheaderauthorizationapikeyapi_keycookie 这十个关键字之一,值就被替换成 ***(关键字表定义在 Core/Security/SensitiveKeys.csSQL 日志的参数打码复用的是同一份,两条出口共用一份名单,不必各补一次)。大小写不敏感,子串匹配,newPasswordaccess_token 都算命中。嵌套对象和数组,也会递归处理。序列化失败的话,比如入参里带着 IFormFile 这类没法序列化的东西,不会因此挡住请求,只记一个占位串 <unserializable>。脱敏后的 JSON 超过 ParamMaxChars(默认 8192,见运维端点)还会被截断:只留开头并标注原长度,结果仍是合法 JSON。

登录日志记的是原始输入的账号,哪怕这个账号根本不存在,加上具体的失败码,方便事后排查暴力破解或者账号探测。这段逻辑在 AuthService 里。IP 和 UA,由日志服务从当前请求里自动补全。唯独密码,绝不记录

基于 Apache License 2.0 开源