Skip to content

自定义Basic认证

当内置 Bearer、Cookie 等方案不满足需求时,通过继承 AuthenticationHandler 实现自定义认证协议——以 Basic 认证为完整示例。

适用场景

  • 需要接入 Basic、HMAC、签名头、网关票据等自定义协议
  • 希望统一复用 Spire 认证中间件与授权体系
  • 不想把认证逻辑直接散落在业务代码里

不适合能用 JWT Bearer 或 Cookie 解决的标准场景——优先复用内置方案,只有协议确实不兼容时才自定义。

前置条件

自定义认证方案依赖以下包:

用途
soulsoft_web_authentication认证框架核心(AuthenticationHandler 基类、AuthenticationBuilder、结果类型)
soulsoft_identity_claims身份声明模型(ClaimClaimsIdentityClaimsPrincipal
soulsoft_extensions_optionsIOptionsMonitor 接口,用于在 Handler 中读取配置

如需日志记录,额外依赖 soulsoft_extensions_logging

快速开始

下面以 Basic 认证为例,展示完整的自定义方案实现。分三段:定义选项 → 实现 Handler → 注册入口。

核心代码分两段:BasicAuthenticationHandler.handleAuthenticate() 是唯一需要编写的业务逻辑,extend AuthenticationBuilder 是暴露给调用方的注册入口。

cangjie
import soulsoft_web_http.*
import soulsoft_web_authentication.*
import soulsoft_identity_claims.*
import soulsoft_extensions_options.*
import soulsoft_extensions_logging.*
import stdx.encoding.base64.*

public class BasicAuthenticationOptions <: AuthenticationSchemeOptions {
    public var realm: String = "spire"
    public var username: String = "admin"
    public var password: String = "123456"
}

public class BasicAuthenticationHandler <: AuthenticationHandler<BasicAuthenticationOptions> {

    public init(options: IOptionsMonitor<BasicAuthenticationOptions>, logFactory: ILoggerFactory) {
        super(options, logFactory)
    }

    public func handleAuthenticate(): AuthenticateResult {
        let header = this.context.request.headers.authorization.first ?? String.empty
        if (header.isEmpty() || !header.startsWith("Basic ")) {
            return AuthenticateResult.noResult()
        }
        let credential = header[6..]
        if (let Some(bytes) <- fromBase64String(credential)) {
            unsafe {
                let pairStr = String.withRawData(bytes)
                let pair = pairStr.split(":")
                if (pair.size == 2 && pair[0] == this.options.username && pair[1] == this.options.password) {
                    let identity = ClaimsIdentity(Some(this.scheme.name))
                    identity.addClaim(ClaimTypes.Name, pair[0])
                    let principal = ClaimsPrincipal([identity])
                    let ticket = AuthenticationTicket(principal, this.scheme.name)
                    return AuthenticateResult.success(ticket)
                }
            }
        }
        return AuthenticateResult.fail("Invalid credentials")
    }
}

extend AuthenticationBuilder {
    public func addBasic(authenticateScheme: String,
        configureOptions: (BasicAuthenticationOptions) -> Unit): AuthenticationBuilder {
        this.addScheme<BasicAuthenticationOptions, BasicAuthenticationHandler>(
            authenticateScheme, None, configureOptions)
    }
}

注册时调用 addBasic(),与内置方案完全一致的风格:

cangjie
builder.services.addAuthentication("Basic")
    .addBasic("Basic") { options =>
        options.username = "admin"
        options.password = "123456"
    }

这段代码接入后,请求携带 Authorization: Basic YWRtaW46MTIzNDU2 头即可通过认证。认证成功后的授权流程与 JWT Bearer、Cookie 完全一致——都由同一套授权中间件处理。

核心概念

定位与职责

自定义 Basic 认证方案分三步:

  1. 定义 Options——继承 AuthenticationSchemeOptions,承载方案的配置参数
  2. 继承 AuthenticationHandler<TOptions>——重写 handleAuthenticate(),实现凭证读取与校验
  3. 通过 extend AuthenticationBuilder 暴露 addXxx() 注册入口——让调用方像使用内置方案一样注册

框架接管了中间件调度、handler 初始化与缓存、转发逻辑——你只需关注凭证如何读取、如何校验、成功时如何创建票据。

handleAuthenticate() 才是认证逻辑入口

中间件、handler 初始化、缓存和转发逻辑由框架接管。

你真正需要实现的是:

  • 如何读取凭证——从请求头、请求体还是 query string
  • 如何校验凭证——查数据库、验签名、对密码
  • 成功时如何创建 AuthenticationTicket——构建 ClaimsPrincipal 并包装为票据

noResult()fail() 不一样

  • AuthenticateResult.noResult():当前请求里没有可处理的凭证(如没有 Authorization 头或不是 Basic 格式)。表示"跳过这个请求",不产生副作用。
  • AuthenticateResult.fail(...):确实拿到了凭证,但校验失败(如密码错误、签名不匹配)。表示"凭证无效",会影响后续的 challenge 行为和诊断日志。

两者的选择直接影响多方案并存时的行为:noResult() 让框架有机会尝试下一个方案,fail() 则终结认证链路。

三种返回结果的决策指南

场景返回结果示例
请求头不存在或格式不匹配noResult()没有 Authorization 头、不是 "Basic " 前缀
凭证存在但校验失败fail("原因")密码错误、Base64 解码失败、签名不匹配
凭证校验通过success(ticket)用户名密码匹配,构建 ClaimsPrincipal

注意success()AuthenticationTicketClaimsIdentityauthenticationType 参数会影响 isAuthenticated 的判定——如果传 None,即使认证"成功",授权阶段也会认为用户未认证。

常见用法

多方案并存

自定义方案可以和内置方案同时注册,由不同接口指定使用哪个方案:

cangjie
builder.services.addAuthentication(JwtBearerDefaults.Scheme)
    .addJwtBearer(JwtBearerDefaults.Scheme) { options =>
        // JWT Bearer 配置...
    }
    .addBasic("Basic") { options =>
        options.username = "admin"
        options.password = "123456"
    }

// 默认走 JWT Bearer
app.mapGet("/api/data") { ctx => ctx.response.write("data") }
    .requireAuthorization()

// 显式指定走 Basic
app.mapGet("/api/admin") { ctx => ctx.response.write("admin") }
    .requireAuthorization(["Basic"])

每个方案的 handler 互不干扰。useAuthentication() 按默认方案执行认证,requireAuthorization(["Basic"]) 则让授权阶段按指定方案认证。

配合授权策略

自定义方案认证成功后,可以和内置方案一样使用授权策略做细粒度权限控制:

cangjie
builder.services.addAuthorization() { options =>
    options.addPolicy("AdminOnly") { policy =>
        policy.requireClaim("role", ["admin"])
    }
}

app.mapGet("/admin") { ctx =>
    ctx.response.write("admin")
}.requireAuthorization(["AdminOnly"])

认证只负责"用户是谁",授权策略负责"这个用户能不能访问"——两者的分工与内置方案完全一致。

生产建议

  • 优先复用现有 Bearer / Cookie / OIDC 方案,只有确实需要时再自定义。内置方案经过充分测试,自定义方案的密钥管理、时效控制、安全防护都需要自己保证。
  • 自定义方案也应尽量接入配置、日志和统一 challenge 行为。不要让自定义方案成为"野生代码"——至少要做到配置化(通过 Options)、可排查(通过 ILoggerFactory)、行为一致(challenge 返回合理的 HTTP 状态码)。
  • 如果需要多方案并存,配合认证转发和授权策略一起设计。规划好哪个方案是默认方案、哪些接口用特定方案、认证失败时是否 forward 到另一个方案重试。

常见问题

自定义方案如何接入配置文件

继承 AuthenticationSchemeOptions 本身不会自动绑定配置文件。JWT Bearer 等内置方案之所以支持从 authentication.schemes.<方案名> 节点读取配置,是因为它们额外实现了 IConfigureNamedOptions<TOptions>(如 JwtBearerConfigureOptions)并显式从配置节读取属性。自定义方案如需同样的配置绑定能力,需要自行实现对应的 IConfigureNamedOptions<YourOptions> 并注册到 DI 容器。

handleAuthenticate() 里能调用异步操作吗

当前框架的认证处理器不支持异步。如果凭证校验需要访问数据库或外部服务,建议在校验前通过事件扩展或中间件预处理,将必要数据挂到 HttpContext 上,再在 handleAuthenticate() 中读取。

fail()noResult() 之后请求会怎样

两者都不会让请求立即中断。认证中间件只是不走入 context.user,请求继续往后走到授权中间件。授权中间件根据端点是否标注 requireAuthorization() 决定:未标注的接口直接放行,标注了的接口触发 challenge() 返回 401。

相关专题