身份认证
认证只解决一件事:把“这是谁”解析出来,并挂到 HttpContext.user。
它不直接决定“能不能访问资源”。真正决定是否放行,通常是:
- 后续的授权中间件
- 终结点里的显式判断
- 某个
challenge()/forbid()调用
这也是这套架构最值得注意的地方:认证负责产出身份,授权负责消费身份,两者解耦。
最小示例
import soulsoft_web_hosting.*
import soulsoft_web_routing.*
import soulsoft_web_authentication.*
import soulsoft_extensions_injection.*
main(args: Array<String>): Int64 {
let builder = WebHost.createBuilder(args)
builder.services.addRouting()
// 注册认证基础设施
builder.services.addAuthentication("Basic")
let app = builder.build()
// 典型顺序:先路由,再认证,再授权
app.useRouting()
app.useAuthentication()
app.mapGet("/ping") { ctx =>
ctx.response.write("pong")
}
app.run()
return 0
}addAuthentication("Basic") 的含义是把 Basic 设为默认方案。之后:
context.authenticate()会优先用Basiccontext.challenge()会优先挑战Basiccontext.forbid()也会优先走默认方案
认证链路
认证中间件做的事情并不多,但职责分得很清楚:
- 给当前请求挂上认证特征
- 先执行
IAuthenticationRequestHandler类型的方案 - 再执行默认认证方案
- 认证成功后把
principal写入context.user - 然后继续进入后续中间件和终结点
也就是说,默认认证失败不会自动返回 401。请求仍然会继续往下走。
时序图
下面这张图对应 useAuthentication() 的真实执行路径:
这张图里最重要的不是“谁先谁后”,而是三个边界:
SchemeProvider只负责找方案HandlerProvider只负责拿处理器实例并按请求初始化AuthenticationHandler只负责该方案自己的认证逻辑
这就是扩展起来很舒服的原因。
架构拆分
AuthenticationMiddleware
认证中间件负责把认证接到请求管道里。它本身不解析 Token,也不读 Cookie,更不关心 JWT、Basic 还是别的方案。
AuthenticationSchemeProvider
方案提供器负责管理:
- 默认认证方案
- 默认挑战方案
- 默认禁止方案
- 所有已注册方案
因此“当前该用哪个方案”是可配置的,不需要写死在中间件里。
AuthenticationHandlerProvider
处理器提供器负责:
- 按方案名取 handler
- 用当前
HttpContext初始化 handler - 在同一个请求内缓存 handler 实例
所以 handler 可以天然拿到当前请求上下文,不需要你手动传一堆参数。
AuthenticationHandler<TOptions>
这是最核心的扩展点。自定义方案时,你通常只需要做三件事:
- 定义
Options - 继承
AuthenticationHandler<TOptions> - 重写
handleAuthenticate(),必要时重写handleChallenge()
其它方案选择、实例创建、HttpContext 集成、结果挂载,都由框架替你完成。
一个经常被忽略的设计点
框架把“认证”和“接管请求”拆成了两个层级:
- 普通方案实现
AuthenticationHandler<TOptions> - 特殊方案还可以实现
IAuthenticationRequestHandler
后者多了一个 handleRequest(): Bool,可以在认证中间件阶段直接截断请求。
这非常适合:
- 外部登录回调
- OIDC 登录跳转
- 需要主动改写响应的协议握手场景
而 Basic、Bearer、Cookie 这种常规方案,通常只需要普通 AuthenticationHandler<TOptions> 就够了。
自定义 Basic 认证方案
下面这段代码是这套架构最能体现“扩展点清晰”的地方。你不需要改中间件,不需要改服务层,只要增加一个 Options、一个 Handler,再注册成一个新 scheme。
这段示例使用
stdx.encoding.base64解析Authorization: Basic ...。如果你的项目没有引入 stdx,也可以把 Base64 解码替换成你自己的实现。
1. 定义选项
import soulsoft_web_authentication.*
public class BasicAuthenticationOptions <: AuthenticationSchemeOptions {
public var realm: String = "spire"
public var username: String = "admin"
public var password: String = "123456"
}2. 实现 Handler
import soulsoft_web_http.*
import soulsoft_web_authentication.*
import soulsoft_identity_claims.*
import soulsoft_extensions_logging.*
import soulsoft_extensions_options.*
import stdx.encoding.base64.*
public class BasicAuthenticationHandler <: AuthenticationHandler<BasicAuthenticationOptions> {
public init(options: IOptionsMonitor<BasicAuthenticationOptions>, loggerFactory: ILoggerFactory) {
super(options, loggerFactory)
}
protected override func handleAuthenticate(): AuthenticateResult {
let authorization = context.request.headers.authorization.first ?? String.empty
if (authorization.isEmpty()) {
return AuthenticateResult.noResult()
}
if (!authorization.toAsciiLower().startsWith("basic ")) {
return AuthenticateResult.noResult()
}
let encoded = authorization[6..authorization.size].trimAscii()
let decoded = fromBase64String(encoded)
if (decoded.isNone()) {
return AuthenticateResult.fail("Invalid basic authorization header.")
}
let plain = String.fromUtf8(decoded.getOrThrow())
if (let Some(separator) <- plain.indexOf(":")) {
let username = plain[0..separator]
let password = plain[(separator + 1)..plain.size]
if (username == options.username && password == options.password) {
let identity = ClaimsIdentity(Some(scheme.name))
identity.addClaim(ClaimTypes.Name, username)
identity.addClaim(ClaimTypes.Amr, "Basic")
let principal = ClaimsPrincipal([identity])
let ticket = AuthenticationTicket(principal, scheme.name)
return AuthenticateResult.success(ticket)
}
}
return AuthenticateResult.fail("Invalid username or password.")
}
protected override func handleChallenge(_: AuthenticationProperties): Unit {
context.response.statusCode = StatusCodes.Unauthorized
context.response.headers.wwwAuthenticate = ["Basic realm=\"${options.realm}\""]
}
}这段代码真正需要你关心的只有两件事:
- 如何从请求里解析凭据
- 凭据通过后,如何构造
ClaimsPrincipal和AuthenticationTicket
其它事情,例如:
- 方案选择
- handler 生命周期
- 请求上下文注入
- 认证结果缓存
context.user赋值
都已经在框架里了。
3. 给 Builder 补一个 addBasic(...)
import soulsoft_web_authentication.*
public interface BasicAuthenticationBuilderExtensions {
func addBasic(scheme: String, configureOptions: (BasicAuthenticationOptions) -> Unit): AuthenticationBuilder
}
extend AuthenticationBuilder <: BasicAuthenticationBuilderExtensions {
public func addBasic(scheme: String, configureOptions: (BasicAuthenticationOptions) -> Unit): AuthenticationBuilder {
return addScheme<BasicAuthenticationOptions, BasicAuthenticationHandler>(
scheme,
Some("Basic"),
configureOptions
)
}
}这一步不是必须的,但它很能体现这套设计的魅力:
- 方案实现和注册入口是分开的
- 你的业务库可以像 Cookie、JWT 一样提供自己的扩展方法
- 应用层只需要
addBasic(...),不需要知道 handler 的内部细节
4. 在应用里注册并使用
import soulsoft_web_hosting.*
import soulsoft_web_routing.*
import soulsoft_web_http.*
import soulsoft_web_authentication.*
import soulsoft_identity_claims.*
import soulsoft_extensions_injection.*
main(args: Array<String>): Int64 {
let builder = WebHost.createBuilder(args)
builder.services.addRouting()
builder.services.addAuthentication("Basic")
.addBasic("Basic") { options =>
options.realm = "demo-api"
options.username = "spire"
options.password = "secret"
}
let app = builder.build()
app.useRouting()
app.useAuthentication()
app.mapGet("/me") { ctx =>
if (ctx.user.identity.isNone()) {
ctx.challenge("Basic")
return
}
let name = ctx.user.findFirstValue(ClaimTypes.Name) ?? "anonymous"
ctx.response.write("hello ${name}")
}
app.run()
return 0
}这样一来,请求里带上:
Authorization: Basic c3BpcmU6c2VjcmV0就会被 BasicAuthenticationHandler 解析成用户身份,并自动写入 context.user。
Basic 方案时序图
上面的 Basic 方案在运行时,真正发生的是下面这条链:
这张图最能说明这套设计为什么顺手:
- 中间件不关心 Basic 细节
- Handler 不关心管道编排
- 终结点不关心 Base64 解析
- 每一层只负责自己那一小块
几个实用结论
1. useAuthentication() 不是“保护接口”
它只是把身份放到 context.user。
如果你想真正阻止匿名访问,需要:
- 接入授权中间件
- 或者在终结点里显式
challenge()
2. 默认方案非常重要
如果没传 scheme,authenticate()、challenge()、forbid() 都会去找默认方案。
所以这类写法最省事:
builder.services.addAuthentication("Basic")3. 自定义方案优先重写这两个点
大多数自定义认证方案只需要关心:
handleAuthenticate()handleChallenge()
只有涉及登录态持久化时,才需要进一步实现 signIn / signOut。
4. 认证失败和无结果不是一回事
AuthenticateResult.fail(...):说明确实尝试过认证,但失败了AuthenticateResult.noResult():说明这个方案没有处理当前请求
这个区分很有价值,因为多方案组合时它能避免错误地“抢走”本不该自己处理的请求。
一个推荐顺序
如果你的应用同时用了路由、认证和授权,推荐顺序是:
app.useRouting()
app.useAuthentication()
app.useAuthorization()这个顺序的含义很直接:
- 路由先完成终结点匹配
- 认证把身份挂到
context.user - 授权再根据终结点元数据和当前身份决定是否放行