Cookie 认证
Cookie 方案适合浏览器登录态。
它把 AuthenticationTicket 保护后写进 Cookie,请求到达时再把 Cookie 还原成 ClaimsPrincipal,最后挂到 HttpContext.user。
和 JWT Bearer 最大的区别不是“存在哪里”,而是默认交互语义:
- Cookie 的
challenge()默认跳登录页 - Cookie 的
forbid()默认跳拒绝访问页 - 只有 Ajax 请求才默认返回
401/403
最小示例
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
}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
62
63
64
65
addCookie(...) 实际会做三件事:
- 自动注册数据保护服务
addDataProtection() - 注册 Cookie 方案到
CookieAuthenticationHandler - 在
PostConfigureCookieAuthenticationOptions里补齐默认 Cookie 名称和ticketDataFormat
如果你没有手动设置 options.cookie.name,默认值会变成:
.cangjie.<scheme>登录写 Cookie
Cookie 方案最核心的入口不是 authenticate(),而是 signIn()。
这里最值得注意的是:
- 票据写入时会先经过
TicketDataFormat TicketDataFormat会先序列化AuthenticationTicket,再用IDataProtector保护,最后做 Base64Url 编码- 如果配置了
sessionStore,Cookie 里不再保存完整票据,而是只保存一个会话键
这就是 Cookie 方案的扩展点魅力所在:既能直接存票据,也能切到服务端会话。
带 Cookie 的请求
执行流程
handleAuthenticate()先从请求 Cookie 中取出options.cookie.name对应的值。ticketDataFormat.unprotect(...)失败时,认证直接失败;Cookie 缺失时返回noResult()。- 如果配置了
sessionStore,处理器会从票据里的SessionId声明取回真正的票据;取不回来也会失败。 - 票据过期时,处理器会在使用
sessionStore的情况下顺带删除服务端会话,再返回失败结果。 - 认证通过后会触发
onValidatePrincipal。这里可以替换 principal,也可以通过rejectPrincipal()直接拒绝当前登录态。 - 启用
slidingExpiration时,处理器会比较“已过去多久”和“还剩多久”;默认在剩余时间少于已用时间时请求续期。 - 真正的续期写回发生在
finishResponse(),也就是本次请求快结束时,而不是在认证瞬间立刻重写 Cookie。
这一段决定了 Cookie 方案的两个特征:
- 登录态是可撤销的,因为
onValidatePrincipal和sessionStore都可以让旧票据失效 - 登录态是可续期的,但续期时机由处理器和事件共同决定
Challenge 与 Forbid
Cookie 方案和 Bearer 方案最大的语义差别在这里。
CookieAuthenticationHandler 的默认行为是:
handleChallenge()组装loginPath + ?returnUrl=当前请求地址handleForbidden()组装accessDeniedPath + ?returnUrl=当前请求地址- 然后分别触发
onRedirectToLogin和onRedirectToAccessDenied
默认事件会区分 Ajax 与普通浏览器请求:
- 普通请求:
302重定向 - Ajax 请求:写入
Location响应头,并返回401或403
Ajax 的判定依据是:
- 请求头
X-Requested-With: XMLHttpRequest - 或查询参数里存在同名值
所以 Cookie 方案更像“浏览器登录流程”,不是“纯 API Token 流程”。
登录后跳转与登出后跳转
signIn() 和 signOut() 结束后,处理器都会调用 applyHeaders(...):
- 写入
Cache-Control: no-cache,no-store - 写入
Pragma: no-cache - 写入过期时间头
如果满足下面任一条件,还会继续触发 onRedirectToReturnUrl:
AuthenticationProperties.redirectUri已显式设置- 当前路径正好是
loginPath或logoutPath,并且查询串里带了合法的returnUrl
这里的 returnUrl 必须是站内相对路径,处理器会拒绝非 host-relative 地址。
服务端会话存储
如果你不想把完整票据放进 Cookie,可以实现 ITicketStore:
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
}
}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
注册方式:
builder.services.addAuthentication(CookieAuthenticationDefaults.Scheme)
.addCookie(CookieAuthenticationDefaults.Scheme) { options =>
// Cookie 中只保留会话键,完整票据转存到服务端
options.sessionStore = MemoryTicketStore()
options.cookie.name = ".spire.session"
}2
3
4
5
6
这类模式特别适合:
- 票据声明很多,Cookie 容量容易超限
- 需要服务端主动注销某个会话
- 需要统一控制会话续期与回收
事件与扩展点
Cookie 方案真正灵活的地方,不在于“能不能写 Cookie”,而在于你能介入每个关键节点。
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")
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
最常用的几个点:
onSigningIn:登录前调整cookieOptions或propertiesonValidatePrincipal:按请求重新校验用户状态onCheckSlidingExpiration:覆盖默认续期判断onRedirectToLogin/onRedirectToAccessDenied:改写默认跳转行为
几个实用结论
1. useAuthentication() 不会创建 Cookie
它只负责在请求阶段读取 Cookie 并恢复身份。
真正写 Cookie 的入口是:
ctx.signIn(...)ctx.signOut(...)
2. Cookie 方案默认更偏浏览器
如果你在 API 里直接用 Cookie,又没有改写重定向事件,那么匿名访问受保护终结点时看到的通常是跳转,不是裸 401。
3. 滑动续期不是无限续命
它依赖这些条件同时成立:
- 票据里有
issuedUtc和expiresUtc slidingExpiration = trueallowRefresh != false- 续期判断或事件最终给出了
shouldRenew = true
4. sessionStore 会改变 Cookie 内容形态
启用后,Cookie 里保存的不是完整业务身份,而是一个内部会话键。真正的 AuthenticationTicket 在服务端存储中。