Skip to content

身份认证

认证只解决一件事:把“这是谁”解析出来,并挂到 HttpContext.user

它不直接决定“能不能访问资源”。真正决定是否放行,通常是:

  • 后续的授权中间件
  • 终结点里的显式判断
  • 某个 challenge() / forbid() 调用

这也是这套架构最值得注意的地方:认证负责产出身份,授权负责消费身份,两者解耦。

最小示例

cangjie
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() 会优先用 Basic
  • context.challenge() 会优先挑战 Basic
  • context.forbid() 也会优先走默认方案

认证链路

认证中间件做的事情并不多,但职责分得很清楚:

  1. 给当前请求挂上认证特征
  2. 先执行 IAuthenticationRequestHandler 类型的方案
  3. 再执行默认认证方案
  4. 认证成功后把 principal 写入 context.user
  5. 然后继续进入后续中间件和终结点

也就是说,默认认证失败不会自动返回 401。请求仍然会继续往下走。

时序图

下面这张图对应 useAuthentication() 的真实执行路径:

这张图里最重要的不是“谁先谁后”,而是三个边界:

  • SchemeProvider 只负责找方案
  • HandlerProvider 只负责拿处理器实例并按请求初始化
  • AuthenticationHandler 只负责该方案自己的认证逻辑

这就是扩展起来很舒服的原因。

架构拆分

AuthenticationMiddleware

认证中间件负责把认证接到请求管道里。它本身不解析 Token,也不读 Cookie,更不关心 JWT、Basic 还是别的方案。

AuthenticationSchemeProvider

方案提供器负责管理:

  • 默认认证方案
  • 默认挑战方案
  • 默认禁止方案
  • 所有已注册方案

因此“当前该用哪个方案”是可配置的,不需要写死在中间件里。

AuthenticationHandlerProvider

处理器提供器负责:

  • 按方案名取 handler
  • 用当前 HttpContext 初始化 handler
  • 在同一个请求内缓存 handler 实例

所以 handler 可以天然拿到当前请求上下文,不需要你手动传一堆参数。

AuthenticationHandler<TOptions>

这是最核心的扩展点。自定义方案时,你通常只需要做三件事:

  1. 定义 Options
  2. 继承 AuthenticationHandler<TOptions>
  3. 重写 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. 定义选项

cangjie
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

cangjie
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}\""]
    }
}

这段代码真正需要你关心的只有两件事:

  • 如何从请求里解析凭据
  • 凭据通过后,如何构造 ClaimsPrincipalAuthenticationTicket

其它事情,例如:

  • 方案选择
  • handler 生命周期
  • 请求上下文注入
  • 认证结果缓存
  • context.user 赋值

都已经在框架里了。

3. 给 Builder 补一个 addBasic(...)

cangjie
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. 在应用里注册并使用

cangjie
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
}

这样一来,请求里带上:

text
Authorization: Basic c3BpcmU6c2VjcmV0

就会被 BasicAuthenticationHandler 解析成用户身份,并自动写入 context.user

Basic 方案时序图

上面的 Basic 方案在运行时,真正发生的是下面这条链:

这张图最能说明这套设计为什么顺手:

  • 中间件不关心 Basic 细节
  • Handler 不关心管道编排
  • 终结点不关心 Base64 解析
  • 每一层只负责自己那一小块

几个实用结论

1. useAuthentication() 不是“保护接口”

它只是把身份放到 context.user

如果你想真正阻止匿名访问,需要:

  • 接入授权中间件
  • 或者在终结点里显式 challenge()

2. 默认方案非常重要

如果没传 scheme,authenticate()challenge()forbid() 都会去找默认方案。

所以这类写法最省事:

cangjie
builder.services.addAuthentication("Basic")

3. 自定义方案优先重写这两个点

大多数自定义认证方案只需要关心:

  • handleAuthenticate()
  • handleChallenge()

只有涉及登录态持久化时,才需要进一步实现 signIn / signOut

4. 认证失败和无结果不是一回事

  • AuthenticateResult.fail(...):说明确实尝试过认证,但失败了
  • AuthenticateResult.noResult():说明这个方案没有处理当前请求

这个区分很有价值,因为多方案组合时它能避免错误地“抢走”本不该自己处理的请求。

一个推荐顺序

如果你的应用同时用了路由、认证和授权,推荐顺序是:

cangjie
app.useRouting()
app.useAuthentication()
app.useAuthorization()

这个顺序的含义很直接:

  • 路由先完成终结点匹配
  • 认证把身份挂到 context.user
  • 授权再根据终结点元数据和当前身份决定是否放行