Skip to content

Cookie 认证

Cookie 方案适合浏览器登录态。

它把 AuthenticationTicket 保护后写进 Cookie,请求到达时再把 Cookie 还原成 ClaimsPrincipal,最后挂到 HttpContext.user

和 JWT Bearer 最大的区别不是“存在哪里”,而是默认交互语义:

  • Cookie 的 challenge() 默认跳登录页
  • Cookie 的 forbid() 默认跳拒绝访问页
  • 只有 Ajax 请求才默认返回 401 / 403

最小示例

cangjie
import soulsoft_web_hosting.*
import soulsoft_web_http.*
import soulsoft_web_routing.*
import soulsoft_web_authorization.*
import soulsoft_web_authentication.*
import soulsoft_web_authentication_cookies.*
import soulsoft_identity_claims.*
import soulsoft_extensions_injection.*

main(args: Array<String>): Int64 {
    let builder = WebHost.createBuilder(args)
    builder.services.addRouting()

    // 把 Cookies 设为默认认证方案
    builder.services.addAuthentication(CookieAuthenticationDefaults.Scheme)
        .addCookie(CookieAuthenticationDefaults.Scheme) { options =>
            options.loginPath = PathString("/login")
            options.logoutPath = PathString("/logout")
            options.accessDeniedPath = PathString("/denied")
            options.expireTimeSpan = Duration.day * 7
            options.slidingExpiration = true

            // Cookie 自身的名称与属性
            options.cookie.name = ".spire.auth"
            options.cookie.path = "/"
            options.cookie.httpOnly = true
        }

    builder.services.addAuthorization()

    let app = builder.build()

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

    app.mapPost("/login") { ctx =>
        let identity = ClaimsIdentity(Some(CookieAuthenticationDefaults.Scheme))
        identity.addClaim(ClaimTypes.Name, "spire")
        identity.addClaim("role", "admin")

        let principal = ClaimsPrincipal([identity])
        let properties = AuthenticationProperties()

        // 持久化 Cookie,并在登录成功后跳回目标页
        properties.isPersistent = true
        properties.redirectUri = "/profile"

        ctx.signIn(principal, properties)
    }

    app.mapPost("/logout") { ctx =>
        let properties = AuthenticationProperties()
        properties.redirectUri = "/"
        ctx.signOut(properties)
    }

    app.mapGet("/profile") { ctx =>
        let name = ctx.user.findFirstValue(ClaimTypes.Name) ?? "anonymous"
        ctx.response.write("hello ${name}")
    }.requireAuthorization()

    app.run()
    return 0
}

addCookie(...) 实际会做三件事:

  • 自动注册数据保护服务 addDataProtection()
  • 注册 Cookie 方案到 CookieAuthenticationHandler
  • PostConfigureCookieAuthenticationOptions 里补齐默认 Cookie 名称和 ticketDataFormat

如果你没有手动设置 options.cookie.name,默认值会变成:

text
.cangjie.<scheme>

Cookie 方案最核心的入口不是 authenticate(),而是 signIn()

这里最值得注意的是:

  • 票据写入时会先经过 TicketDataFormat
  • TicketDataFormat 会先序列化 AuthenticationTicket,再用 IDataProtector 保护,最后做 Base64Url 编码
  • 如果配置了 sessionStore,Cookie 里不再保存完整票据,而是只保存一个会话键

这就是 Cookie 方案的扩展点魅力所在:既能直接存票据,也能切到服务端会话。

执行流程

  1. handleAuthenticate() 先从请求 Cookie 中取出 options.cookie.name 对应的值。
  2. ticketDataFormat.unprotect(...) 失败时,认证直接失败;Cookie 缺失时返回 noResult()
  3. 如果配置了 sessionStore,处理器会从票据里的 SessionId 声明取回真正的票据;取不回来也会失败。
  4. 票据过期时,处理器会在使用 sessionStore 的情况下顺带删除服务端会话,再返回失败结果。
  5. 认证通过后会触发 onValidatePrincipal。这里可以替换 principal,也可以通过 rejectPrincipal() 直接拒绝当前登录态。
  6. 启用 slidingExpiration 时,处理器会比较“已过去多久”和“还剩多久”;默认在剩余时间少于已用时间时请求续期。
  7. 真正的续期写回发生在 finishResponse(),也就是本次请求快结束时,而不是在认证瞬间立刻重写 Cookie。

这一段决定了 Cookie 方案的两个特征:

  • 登录态是可撤销的,因为 onValidatePrincipalsessionStore 都可以让旧票据失效
  • 登录态是可续期的,但续期时机由处理器和事件共同决定

Challenge 与 Forbid

Cookie 方案和 Bearer 方案最大的语义差别在这里。

CookieAuthenticationHandler 的默认行为是:

  • handleChallenge() 组装 loginPath + ?returnUrl=当前请求地址
  • handleForbidden() 组装 accessDeniedPath + ?returnUrl=当前请求地址
  • 然后分别触发 onRedirectToLoginonRedirectToAccessDenied

默认事件会区分 Ajax 与普通浏览器请求:

  • 普通请求:302 重定向
  • Ajax 请求:写入 Location 响应头,并返回 401403

Ajax 的判定依据是:

  • 请求头 X-Requested-With: XMLHttpRequest
  • 或查询参数里存在同名值

所以 Cookie 方案更像“浏览器登录流程”,不是“纯 API Token 流程”。

登录后跳转与登出后跳转

signIn()signOut() 结束后,处理器都会调用 applyHeaders(...)

  • 写入 Cache-Control: no-cache,no-store
  • 写入 Pragma: no-cache
  • 写入过期时间头

如果满足下面任一条件,还会继续触发 onRedirectToReturnUrl

  • AuthenticationProperties.redirectUri 已显式设置
  • 当前路径正好是 loginPathlogoutPath,并且查询串里带了合法的 returnUrl

这里的 returnUrl 必须是站内相对路径,处理器会拒绝非 host-relative 地址。

服务端会话存储

如果你不想把完整票据放进 Cookie,可以实现 ITicketStore

cangjie
import std.collection.*
import std.time.*
import soulsoft_web_authentication.*
import soulsoft_web_authentication_cookies.*

public class MemoryTicketStore <: ITicketStore {
    private let tickets = HashMap<String, AuthenticationTicket>()

    public func store(ticket: AuthenticationTicket): String {
        let key = "sid-" + DateTime.now().toString()
        tickets[key] = ticket
        return key
    }

    public func renew(key: String, ticket: AuthenticationTicket): Unit {
        tickets[key] = ticket
    }

    public func retrieve(key: String): ?AuthenticationTicket {
        tickets[key]
    }

    public func remove(key: String): ?AuthenticationTicket {
        let value = tickets[key]
        tickets.remove(key)
        return value
    }
}

注册方式:

cangjie
builder.services.addAuthentication(CookieAuthenticationDefaults.Scheme)
    .addCookie(CookieAuthenticationDefaults.Scheme) { options =>
        // Cookie 中只保留会话键,完整票据转存到服务端
        options.sessionStore = MemoryTicketStore()
        options.cookie.name = ".spire.session"
    }

这类模式特别适合:

  • 票据声明很多,Cookie 容量容易超限
  • 需要服务端主动注销某个会话
  • 需要统一控制会话续期与回收

事件与扩展点

Cookie 方案真正灵活的地方,不在于“能不能写 Cookie”,而在于你能介入每个关键节点。

cangjie
builder.services.addAuthentication(CookieAuthenticationDefaults.Scheme)
    .addCookie(CookieAuthenticationDefaults.Scheme) { options =>
        options.events.onValidatePrincipal = { context =>
            // 在每次请求上复核当前登录态
            let role = context.principal.flatMap { p => p.findFirstValue("role") } ?? "guest"
            if (role == "disabled") {
                context.rejectPrincipal()
                return
            }

            // 主动要求本次响应刷新 Cookie
            context.shouldRenew = true
        }

        options.events.onRedirectToLogin = { context =>
            // API 风格端点不想跳转时,可以改成直接返回 401
            context.response.statusCode = StatusCodes.Unauthorized
            context.response.write("please login first")
        }
    }

最常用的几个点:

  • onSigningIn:登录前调整 cookieOptionsproperties
  • onValidatePrincipal:按请求重新校验用户状态
  • onCheckSlidingExpiration:覆盖默认续期判断
  • onRedirectToLogin / onRedirectToAccessDenied:改写默认跳转行为

几个实用结论

它只负责在请求阶段读取 Cookie 并恢复身份。

真正写 Cookie 的入口是:

  • ctx.signIn(...)
  • ctx.signOut(...)

如果你在 API 里直接用 Cookie,又没有改写重定向事件,那么匿名访问受保护终结点时看到的通常是跳转,不是裸 401

3. 滑动续期不是无限续命

它依赖这些条件同时成立:

  • 票据里有 issuedUtcexpiresUtc
  • slidingExpiration = true
  • allowRefresh != false
  • 续期判断或事件最终给出了 shouldRenew = true

启用后,Cookie 里保存的不是完整业务身份,而是一个内部会话键。真正的 AuthenticationTicket 在服务端存储中。