Cookie 认证
适合浏览器登录态、服务端页面和需要会话持久化的场景——加密票据写入 Cookie,请求自动携带并还原用户身份。
适用场景
- 浏览器页面登录
- 需要服务端保存会话状态
- 更偏传统 Web 应用,而不是纯 API Token 鉴权
不适合纯 API、移动端、前后端分离场景——那种情况优先用 JWT Bearer 认证。
前置条件
Cookie 认证依赖以下包:
| 包 | 用途 |
|---|---|
soulsoft_web_authentication | 认证框架核心(中间件、Handler 基类、方案注册) |
soulsoft_web_authentication_cookies | Cookie 处理器、选项、事件上下文、ITicketStore 接口 |
soulsoft_extensions_protection | 数据保护 —— 加密票据写入 Cookie、解密票据还原身份 |
soulsoft_identity_claims | 身份声明模型(Claim、ClaimsIdentity、ClaimsPrincipal) |
所有包均通过 cjpm.toml 声明依赖后即可使用。addCookie() 内部会自动调用 addDataProtection() 完成注册,不需要手动重复注册。
快速开始
下面是一个完整可运行的示例:注册 Cookie 认证、实现登录/登出、保护需要登录的接口。
import std.time.*
import soulsoft_web_http.*
import soulsoft_web_routing.*
import soulsoft_web_hosting.*
import soulsoft_identity_claims.*
import soulsoft_web_authorization.*
import soulsoft_web_authentication.*
import soulsoft_extensions_injection.*
import soulsoft_web_authentication_cookies.*
main(args: Array<String>): Int64 {
let builder = WebHost.createBuilder(args)
builder.services.addRouting()
// 指定默认认证方案为 Cookie
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 // 票据有效期 7 天
options.slidingExpiration = true // 滑动过期
options.cookie.name = ".spire.auth" // Cookie 名称
options.cookie.httpOnly = true // 防 XSS 读取
}
builder.services.addAuthorization()
let app = builder.build()
app.useRouting()
app.useAuthentication()
app.useAuthorization()
// /login — 验证凭据后 signIn() 将加密票据写入 Cookie
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()
properties.isPersistent = true
properties.redirectUri = "/profile"
ctx.signIn(principal, properties)
}
// /logout — 清除 Cookie
app.mapPost("/logout") { ctx =>
let properties = AuthenticationProperties()
properties.redirectUri = "/"
ctx.signOut(properties)
}
// /profile — requireAuthorization() 要求登录后才能访问
app.mapGet("/profile") { ctx =>
let name = ctx.user.findFirstValue(ClaimTypes.Name) ?? "anonymous"
ctx.response.write("hello ${name}")
}.requireAuthorization()
app.run()
return 0
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
核心概念
定位与职责
Cookie 方案会把认证票据保护后写入浏览器 Cookie,并在后续请求中还原为用户身份。
与 JWT Bearer 的核心区别是:
- Bearer 更偏无状态 API——Token 由客户端显式携带
- Cookie 更偏浏览器会话——浏览器自动管理 Cookie 的存储和发送
默认行为也据此不同:
challenge()→ 302 跳登录页(假设用户在用浏览器)forbid()→ 302 跳拒绝页(假设需要展示错误页面)
如果用于 API 场景,需要通过事件扩展改写默认的重定向行为,返回 401 / 403 状态码。
票据存储方式
认证票据有两种存储策略:
| 方式 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| Cookie 直接存储 | 票据加密后整个写入 Cookie | 无状态,无需服务端存储 | Cookie 体积较大(通常几 KB) |
| 服务端会话存储 | Cookie 只存会话键,票据留在服务端 | Cookie 极小,票据可随时撤销 | 需要维护服务端存储 |
两种方式可以随时切换——通过实现 ITicketStore 接口并赋给 options.sessionStore 即可。
登录与登出流程
登录:业务端点验证用户凭据后,构建 ClaimsIdentity 和 ClaimsPrincipal,调用 ctx.signIn(principal, properties)。框架会自动创建 AuthenticationTicket,经数据保护加密后写入 Cookie。
登出:调用 ctx.signOut(properties)。框架清除对应 Cookie,并可配置 redirectUri 指定登出后跳转地址。
滑动过期
slidingExpiration = true 时,Cookie 的过期时间会在每次请求时重新计算并顺延。这意味着只要用户持续活跃,登录态就不会过期。
注意:滑动过期只在 Cookie 直接存储模式下由框架自动处理。如果使用 ITicketStore 服务端存储,需要自己在 renew() 方法中实现续期逻辑。
常见用法
服务端会话存储
完整票据存在服务端,Cookie 里只保留键。实现 ITicketStore 接口即可接入:
核心方法是 store(生成唯一键 → 存储票据 → 返回键写入 Cookie)和 retrieve(根据 Cookie 中的键取出票据)。
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
}
}
options.sessionStore = MemoryTicketStore()2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
示例用了 HashMap 做内存存储,生产环境应替换为 Redis 或数据库,以保证多实例共享会话。
事件扩展
Cookie 认证在关键节点提供了事件回调,允许在不修改处理器源码的情况下插入自定义逻辑。
onValidatePrincipal 在票据还原为 ClaimsPrincipal 之后触发,适合做二次校验——示例中检查 role 是否为 "disabled",是则拒绝该票据。onRedirectToLogin 在触发登录跳转之前拦截,示例中改为返回 401 和 JSON 错误信息,适合 API 场景。
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()
}
}
options.events.onRedirectToLogin = { context =>
context.response.statusCode = StatusCodes.Unauthorized
context.response.write("please login first")
}
}2
3
4
5
6
7
8
9
10
11
12
13
其他可用事件:
| 事件 | 触发时机 | 典型用途 |
|---|---|---|
onSigningIn | signIn() 调用后、写入 Cookie 前 | 修改票据属性、记录登录日志 |
onSignedIn | Cookie 写入完成后 | 登录成功后的后置处理 |
onRedirectToAccessDenied | 触发拒绝访问跳转前 | 改写为 403 JSON 响应 |
生产建议
- 浏览器场景优先使用 Cookie 认证。Cookie 的 httpOnly 属性可防 XSS 脚本读取、SameSite 属性可防跨站请求携带,这些安全能力是 Bearer Token 在浏览器环境下天然缺乏的。
- 如果 Cookie 认证用于 API,尽早改写默认
302跳转行为。通过onRedirectToLogin和onRedirectToAccessDenied事件返回401/403状态码,避免客户端收到 HTML 重定向响应。 - 对长期会话使用
slidingExpiration时,要明确过期策略和撤销策略。滑动过期只续期不撤销——如果用户权限在会话期间被修改,票据仍然有效,需要额外实现票据失效机制。 - 生产环境务必设置
cookie.httpOnly = true。这防止客户端 JavaScript 读取 Cookie 内容,降低 XSS 攻击窃取会话的风险。
常见问题
为什么默认不直接返回 401
Cookie 方案默认假设请求来自浏览器页面。未登录用户访问受保护页面时,challenge() 返回 302 跳转到登录页,让用户在浏览器中完成登录后再跳回——这是 Web 应用的标准模式。
如果 API 需要 401,通过事件扩展改写即可,不需要换用其他认证方案。
是否适合纯前后端分离 API
通常不如 JWT Bearer 直接。Cookie 依赖浏览器自动携带,而 SPA 和移动端通常自己管理 Token 和请求头。跨域场景下 Cookie 的 SameSite 策略也会增加配置复杂性。
纯 API 场景更推荐 JWT Bearer 认证。
Cookie 体积过大怎么办
当票据包含大量 claims 时,加密后写入 Cookie 可能导致请求头过大。解决方案是启用服务端会话存储——实现 ITicketStore,Cookie 只存一个短键(几十字节),完整票据留在服务端(Redis/数据库)。
相关专题
- 需要理解认证整体体系 → 认证总览
- 需要 API 无状态认证方案 → JWT Bearer 认证
- 需要自定义认证协议 → 自定义Basic认证
- 需要控制访问权限 → 授权策略