会话技术

HTTP协议的本质:无状态(Stateless)。

  • 无状态意味着服务器不会记住任何关于客户端的过往请求。你第一次访问和第一万次访问,对于服务器来说,都是一个全新的、陌生的请求。

  • 问题出现:Web应用很快就需要“记住”用户,比如用户是否登录、购物车里有什么商品。如果无法记住,用户每点击一个链接就需要重新登录一次,购物车也会被清空。

  • Session的诞生:为了解决这个问题,开发者们引入了“Session”这个逻辑概念。它的目标很简单:在无状态的HTTP之上,人为地创造出一个“有状态的对话(Stateful Conversation)”。

常见的技术有3种:

  1. cookie:我们接下来会看到的技术。安全设计比较完备。Set-CookieCookie是对位的header。

  2. url query param:服务器主动把返回的 html 里所有的<a href>都加上 session id。这种方式是特别不安全的,因为分享链接意味着会话状态暴露。

  3. 隐藏表单。在生成 HTML 页面时,动态地嵌入一个<input>标签,并将其 type 属性设置为 hidden。服务器在处理一个请求时,确定了有一些信息需要“记住”或者传递到下一个请求。这些信息不是给用户看的,而是给程序自己用的。例如:产品ID、用户的唯一标识、一个安全令牌(Token)等。完全不安全(Hidden is NOT Secure):这是它最致命的缺点。“隐藏”不等于“安全”。任何一个懂点的用户都可以通过在浏览器中“查看网页源代码”或使用开发者工具(F12)轻松看到隐藏域的值。

这三种技术都不能真的实现把信息完全从服务端剥离的目的,数据细节仍然是需要放在专门的存储-MySQL、Redis里,而这些载体主要存储 id。

关键 header

每次都应该有的基础Header

  1. Content-Type
  • 作用:告诉浏览器响应体是什么类型的数据。这是最最基本的Header之一。
  • 示例:Content-Type: text/html; charset=utf-8 (HTML页面), Content-Type: application/json (JSON数据), Content-Type: image/jpeg (图片)。
  • 重要性:如果缺失,浏览器只能去猜测内容类型,可能导致页面渲染错误或安全问题,会导致很多文件不能打开,而只能下载
  1. Content-Length 或 Transfer-Encoding
  • 作用:告诉浏览器响应体的长度。
  • Content-Length: 一次性告知总长度。
  • Transfer-Encoding: chunked: 表示响应体是分块传输的,长度未知。
  • 重要性:两者必有其一(对于有响应体的请求),用于浏览器正确接收数据和管理连接。否则response可能会中断。
  1. Date
  • 作用:提供响应消息被创建的日期和时间。
  • 重要性:HTTP/1.1规范要求服务器包含此字段。

强烈推荐每次都包含的Header(尤其是安全和缓存)

  1. Cache-Control
  • 作用:精细地控制浏览器和代理服务器如何缓存此响应。
  • 示例:Cache-Control: no-store (完全不缓存), Cache-Control: public, max-age=3600 (公开缓存1小时)。
  • 重要性:对于提升网站性能和保证数据新鲜度至关重要。
  1. Strict-Transport-Security (HSTS)
  • 作用:强制浏览器在未来的一段时间内,只能通过HTTPS访问此站点。
  • 重要性:极大地提升了网站的安全性,防止中间人攻击。
  1. X-Content-Type-Options
  • 作用:固定为nosniff,防止浏览器对Content-Type进行“嗅探”猜测,避免某些类型的攻击。
  • 重要性:一个简单有效的安全加固措施。
  1. Content-Security-Policy (CSP)
  • 作用:定义一个可信内容源的白名单,帮助抵御XSS(跨站脚本)攻击。
  • 重要性:现代Web安全的核心防御机制之一。

cookie 技术

cookie 是为了“弥补 http 无状态”而特别设计出来的“可重放的 header”。它的定位 small block of data。起源于 Unix 程序员里的小 packet data,recieved and sends back unchanged,本来是网景用来实现虚拟购物车用的,后来逐渐成为严重的隐私问题。为了防止被盗用,通常它不是具体的数据,而是一个加密过的id,或者一个会话id。

  • domain:如果不设置就是默认当前的域(包括子域):d2ksr8gxr5kl2o(子域名).cloudfront(主域名).net(顶级域名):sina.com(主域名),sso.sina.com(二级域名)。

    • d2ksr8gxr5kl2o.cloudfront.net 的 cookie 不能用在任何 xxx.cloudfront.net 上。
  • path:如果不设置则为当前路径的父,所以最好设置为/。

  • MaxAge/Expires:旧版本协议用 Expires,新版本用MaxAge。每次check ticket/ check token,得到了 expire time,都要专门使用 Set-Cookie 来延长 cookie 的寿命,不要忘记。

  • HttpOnly:当设置为true时,表示该 Cookie 只能通过 HTTP/HTTPS 协议访问。这种设置可以防止 JavaScript 通过 document.cookie 访问该 Cookie,从而降低 XSS (跨站脚本)攻击的风险。这是 Web 安全的最佳实践之一,特别适用于存储敏感信息(如认证令牌)的Cookie。

  • Secure: 是否使用 https

  • SameSite:

    • SameSite=Strict:最严格。只有当请求完全来自于你当前正在浏览的网站时,才会发送Cookie。比如你在example.com里点击一个链接跳转到example.com/other,会发送。但如果你在google.com上点击一个链接跳转到example.com,则不会发送。

    • SameSite=Lax (现代浏览器默认值):一个折中。允许在一些顶层导航(比如从其他网站点击链接跳转过来)时发送Cookie,但会阻止在跨站的子请求(如加载图片、iframe、AJAX请求)中发送。

    • SameSite=None:最宽松。允许在所有跨站请求中发送Cookie,但必须同时设置Secure标志(即只能用于HTTPS)

总结:服务器对Set-Cookie的使用是指令性和事件驱动的,而不是每次都无脑重放。

  1. Set-Cookie 是一个特殊的、浏览器能看懂的指令。浏览器识别出这个Header后,会自动地、安全地将Cookie内容保存在自己专门的存储区里。浏览器会自动地检查本地存储的Cookie,如果发现有匹配当前域名和路径的Cookie,就会在后续的请求中自动地添加 Cookie 请求头,把数据发回给服务器。

  2. 服务端通常不需要每次都 Set-Cookie(完全没有这个规范,而且通常是应该极力避免的错误实践),但是如果如果需要动态更新 cookie,则需要设计一个前后端不断接力的 cookie 机制。

  3. 服务端频繁 Set-Cookie 的缺点是:

  • 浪费带宽:每个HTTP响应都增加了一些不必要的Header数据。

    • 逻辑混乱:让开发者和调试工具难以判断Set-Cookie的真实意图。
  1. 服务器只在以下四种明确的场景下,才应该发送Set-Cookie头:

    • 创建Cookie:当用户首次登录或触发某个功能时,服务器需要创建一个新的会话或状态标识,此时发送Set-Cookie来在浏览器中“种下”这个Cookie。

    • 修改Cookie:当需要更新Cookie的值时。一个最经典的安全实践是会话更新(Session Regeneration):当用户登录成功后,服务器应该废弃旧的会话ID(登录前的),并生成一个全新的会话ID,通过Set-Cookie下发给浏览器,以防止会话固定(Session Fixation)攻击。

    • 续期Cookie:如果你的会话策略是“滑动过期”(Sliding Expiration,即用户有操作就自动延长过期时间),那么在用户每次与服务器交互时,服务器都应该发送一个带有新的过期时间的Set-Cookie头,来为会话“续命”。

    • 删除Cookie:删除一个Cookie的标准方法,就是发送一个同名、同路径、同域的Set-Cookie头,并将其过期时间设置为一个过去的时间点(或Max-Age=0)。

与其他 Web Storage(IndexedDB、LocalStorage)对比

Cookie的局限性

  1. 容量太小:每个Cookie的大小被限制在4KB左右,一个域名下的Cookie总数也有限制(通常是20-50个)。这对于存储少量身份信息足够,但无法存储更复杂的数据。

  2. 性能开销:Cookie在每次HTTP请求中都会被完整地携带在请求头里,即使当前请求完全不需要这些数据(比如请求一张图片或一个CSS文件)。如果Cookie很大,会明显增加网络流量,造成不必要的性能浪费。

  3. API不友好:原生操作Cookie的API document.cookie 是一个简单的字符串,读写和解析都非常不便。

  4. 定位不纯粹:Cookie的设计初衷是“服务器与客户端的通信”,而不是纯粹的“客户端本地存储”。

  • 第一步:埋下追踪器

    • 你访问了一个新闻网站 A.com。

    • 这个网站上有一个来自广告联盟 Ad-Network.com 的广告位。当你加载A.com时,你的浏览器也向Ad-Network.com发出了请求以下载广告。

    • 此时,Ad-Network.com 就在你的浏览器里“种下”了一个属于它自己的第三方Cookie。这个Cookie里有一个独一无二的ID,比如 ID=xyz123。

  • 第二步:跨站追踪

    • 过了一会儿,你又去访问一个购物网站 B.com,它恰好也使用了同一个广告联盟 Ad-Network.com。

    • 当你加载B.com时,浏览器同样会请求Ad-Network.com的资源。这时,浏览器会自动带上之前存下的那个ID=xyz123的Cookie。

    • Ad-Network.com 的服务器看到这个ID,立刻就知道:“哦!访问B.com的这个人,就是刚才访问了A.com的那个人!”

  • 第三步:建立你的“数字档案”

    • 你继续访问汽车论坛 C.com,旅游博客 D.com… 只要这些网站上有 Ad-Network.com 的“探针”(广告、分析脚本等),你的行踪就会被一次又一次地记录下来。

    • 久而久之,Ad-Network.com 就围绕着 ID=xyz123 这个Cookie,建立起了一个关于你的、极其详尽的“数字侧写”或“影子档案”

      • 你的兴趣:你对数码产品、汽车、东南亚旅游感兴趣。

      • 你的意图:你最近可能在考虑买车或计划度假。

      • 你的特征:结合数据分析,它甚至能推断出你的大致年龄、消费水平、政治倾向等。

Web Storage(LocalStorage, SessionStorage)的出现解决了这些问题

  1. 容量更大:LocalStorage提供了5-10MB的存储空间,远超Cookie的4KB,可以存储大量的JSON数据、配置信息等。

  2. 性能更优:LocalStorage中存储的数据不会被自动附加到HTTP请求头中。它纯粹是客户端的本地仓库,只在需要时通过JavaScript手动读取。这避免了Cookie带来的性能开销。

  3. API更友好:提供了简单的键值对API,如 localStorage.setItem(‘key’, ‘value’), localStorage.getItem(‘key’),操作非常方便。

  4. 定位纯粹:它的定位就是纯粹的客户端存储,与服务器通信无关。

IndexedDB则更进一步

如果说LocalStorage是一个简单的“键值对”仓库,那么IndexedDB就是一个功能完善的客户端小型数据库。

  1. 解决复杂数据存储:它支持存储大量的结构化数据(JSON对象),并能创建索引来快速查询。这对于需要离线功能、本地数据缓存、PWA(渐进式Web应用)等复杂场景至关重要。

  2. 支持事务:IndexedDB的操作是事务性的,保证了数据操作的原子性、一致性、隔离性和持久性(ACID特性),非常可靠。

  3. 异步API:它的API是异步的,不会阻塞浏览器主线程,适合处理大量数据的读写操作。

总结

特性 Cookie (小饼干) localStorage (文件柜) IndexedDB (图书馆)
核心用途 服务器驱动的状态管理。主要用于服务器识别用户、维持会话(如登录状态) 客户端的简单数据存储。主要用于存储用户偏好、UI状态等纯客户端数据 客户端的复杂数据存储。用于构建离线应用、缓存大量结构化数据
与服务器通信 自动发送。在每次符合条件的HTTP请求中,都会被自动添加到 Cookie 请求头里 从不自动发送。必须由你手动编写JavaScript代码(如 fetch)来读取并发送给服务器 从不自动发送。和 localStorage 一样,必须手动发送
容量大小 非常小,约 4KB 较大,约 5-10MB 巨大,可达数百MB甚至GB级别(取决于浏览器和用户授权)
数据类型 只能是字符串 只能是字符串(通常用 JSON.stringify 存对象) 几乎所有类型:字符串、数字、对象、数组、文件(Blob)等
API易用性 难用。通过 document.cookie 操作,需要手动解析字符串 非常简单。同步的.setItem, getItem 键值对操作 非常复杂。异步的、基于事务和事件的API,像一个真正的数据库
生命周期 可设置过期时间。到期后浏览器自动删除 永久有效。除非用户手动清除或代码主动删除,否则永不过期 永久有效。和 localStorage 一样
访问范围 只能在主线程中访问 只能在主线程中访问 可以在Web Workers (后台线程)中访问,适合处理复杂计算不阻塞页面
  • Cookie 的本质是“服务器的备忘录”,它写在你的浏览器上,每次你访问服务器时,浏览器都会自动带上这个备忘录。它的缺点是容量小,且每次都带上会浪费流量。

  • localStorage 是纯粹“浏览器的记事本”,你想记什么就记什么,服务器完全不知道。只有当你需要时,你才用代码把记事本里的内容告诉服务器。

  • IndexedDB 是“浏览器的数据库”,当你的应用需要在本地存储大量结构化信息(比如一个邮件客户端要离线存储所有邮件),并且需要高效查询时,就用它。

jwt

起因

现代的 http 的客户端实现和服务器端实现并不是真正的无状态。

  1. 针对特定的域,客户端需要 cookie 重放:

    • 但是移动端的浏览器没有 cookie 机制。
    • cookie 重放要处理跨域问题。
  2. 服务器必须使用内存来关联 session id,把 session 的内容放在单一服务器,或者分布式 session 的载体里。这就产生了 session 共享、session 黏着之类的与“无状态”正相反的 http 服务端问题。

核心思想

服务器不再存储Session,而是将用户信息本身加密后生成一个令牌(Token),返给客户端。客户端在后续请求中携带这个Token,服务器只需验证Token的合法性即可,无需查询任何存储。

结构

Header.Payload.Signature

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

  • Header (头部):描述JWT元数据,通常包含两部分:令牌类型(typ: “JWT”)和所使用的签名算法(alg: “HS256”, “RS256”等)。这部分会进行Base64Url编码。

  • Payload (载荷):存放实际需要传递的数据,也称为Claims(声明)。包含一些标准字段(如iss签发者, exp过期时间, sub主题),也可以自定义私有字段(如userId, role)。注意:Payload默认只是Base64Url编码,不是加密,所以不应存放敏感信息。

  • Signature (签名):这是JWT最核心的部分。计算方式是:

    • 将编码后的Header和编码后的Payload用点 . 连接起来。

    • 使用Header中指定的算法,配合一个只有服务器知道的密钥 (Secret),对连接后的字符串进行签名。

签名的作用是:保证Token在传输过程中没有被篡改。当服务器收到Token后,会用同样的算法和密钥重新计算签名。如果计算出的签名与Token中的签名一致,说明数据是可信的;如果不一致,则说明Token是伪造的或被修改过。

验证合法性只要使用单向签名算法就能实现。

在实践中,我们在 jwt 的 claim 里最好只放入一些会话id,把大的数据结构存入 db 或者 redis里,把 jwt的 claim 当作存储指针的地方来用。

例子

1
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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
package security.jwt;


import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import java.util.Date;
import java.util.UUID;

public class JwtUtil {

// 定义密钥 (在真实项目中,应从配置文件中读取,且更为复杂)
private static final String SECRET_KEY = "your-super-secret-key-that-is-long-and-secure";

// 定义签发者

/*
* Issuer和Subject的选择原则:
*
* Issuer:应该使用能唯一标识你的认证服务的字符串,通常可以是:
* - 你的应用域名(如auth.yourdomain.com)
* - 服务名称(如当前代码中的MyAppAuthService)
* - 在微服务架构中,建议使用服务注册中心的名称
*
* Subject:应该使用能唯一标识用户的字符串,通常可以是:
* - 用户ID(如当前代码中的userId)
* - 用户邮箱/用户名(如果它们是唯一的)
* - 不建议使用可变的用户信息作为Subject
*/
private static final String ISSUER = "MyAppAuthService";

// 定义过期时间 (例如:1小时)
private static final long EXPIRATION_TIME_MS = 3600 * 1000;

/**
* 创建一个JWT.
*
* @param userId 用户的唯一标识
* @param role 用户的角色
* @return 生成的JWT字符串
*/
public static String createToken(String userId, String role) {
try {
// 1. 选择签名算法
Algorithm algorithm = Algorithm.HMAC256(SECRET_KEY);

// 2. 生成JWT
return JWT.create()
.withIssuer(ISSUER) // 签发者
.withSubject(userId) // 主题,通常是用户ID
/*
* 不要将所有状态放入JWT:JWT应该保持精简,只包含认证和授权必需的信息
* 推荐做法:
* public static String createToken(String userId, String role, Map<String, Object>
* additionalClaims) { // ...其他代码不变... JWT.create() .withIssuer(ISSUER)
* .withSubject(userId) .withClaim("role", role) // 添加额外声明 .withClaim
* ("userData", additionalClaims) // ...其他代码...}
* 替代方案:
* - 只在JWT中存储引用ID(如购物车ID),然后在服务端存储完整数据
* - 使用短期有效的JWT配合服务端会话存储
* - 对于频繁变更的数据(如购物车),建议使用数据库存储
*/
.withClaim("role", role) // 自定义声明 (payload)
.withIssuedAt(new Date()) // 签发时间
.withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME_MS)) // 过期时间
.withJWTId(UUID.randomUUID().toString()) // JWT的唯一身份标识
/*
* - JWT大小限制:HTTP头部通常有大小限制(如8KB)
* - 安全性:敏感信息不应放在JWT中,因为JWT可以被解码(只是不能篡改)
* - 性能:每次请求都会携带JWT,过大的JWT会影响性能
*
* - JWT的设计初衷:
* - JWT确实是为了实现无状态认证而设计的,但这里的"状态"主要指会话状态(如用户身份、权限等)
* - 它解决了传统session需要服务端存储会话数据的问题,使得服务端只需验证JWT签名即可确认用户身份
* - 实际应用中的权衡:
* - 技术限制:
* - HTTP头部大小限制(通常8KB)
* - JWT一旦签发就无法修改(除非重新生成)
* - 业务需求:
* - 购物车、地址等数据频繁变化
* - 这些数据通常需要与其他系统共享(如支付系统、物流系统)
*/
.sign(algorithm); // 使用算法进行签名
} catch (Exception e) {
// 处理异常,例如记录日志
System.err.println("Error creating JWT: " + e.getMessage());
return null;
}
}

/**
* 验证一个JWT并解码.
*
* @param token JWT字符串
* @return 解码后的JWT对象,如果验证失败则返回null
*/
public static DecodedJWT verifyToken(String token) {
try {
// 1. 选择与创建时相同的签名算法
Algorithm algorithm = Algorithm.HMAC256(SECRET_KEY);

// 2. 构建验证器
JWTVerifier verifier = JWT.require(algorithm)
.withIssuer(ISSUER) // 验证签发者
.build(); // 创建验证器实例

// 3. 执行验证
return verifier.verify(token);
} catch (JWTVerificationException e) {
// 验证失败 (例如:签名不匹配, token过期等)
System.err.println("JWT Verification failed: " + e.getMessage());
return null;
}
}

public static void main(String[] args) {
// --- 模拟登录成功,生成Token ---
String userId = "user123";
String userRole = "admin";
System.out.println("--- Generating Token ---");
String token = createToken(userId, userRole);
System.out.println("Generated Token: " + token);
System.out.println();

// --- 模拟后续请求,验证Token ---
System.out.println("--- Verifying Token ---");
if (token != null) {
DecodedJWT decodedJWT = verifyToken(token);

if (decodedJWT != null) {
System.out.println("Verification Successful!");
String verifiedUserId = decodedJWT.getSubject();
String verifiedRole = decodedJWT.getClaim("role").asString();
Date expiresAt = decodedJWT.getExpiresAt();

System.out.println("User ID: " + verifiedUserId);
System.out.println("User Role: " + verifiedRole);
System.out.println("Expires At: " + expiresAt);
} else {
System.out.println("Verification Failed!");
}
}

System.out.println("\n--- Verifying a Tampered Token ---");
// 模拟一个被篡改的token (在payload部分添加了额外字符)
String tamperedToken =
token.substring(0, token.indexOf('.') + 10) + "tamper" + token.substring(token.indexOf('.') + 10);
System.out.println("Tampered Token: " + tamperedToken);
verifyToken(tamperedToken); // 这将会验证失败
}
}

SSO

精确的SSO登录流程 (结合联邦认证)

这个流程描述了当用户首次访问系统,且 SSO 认证中心本身也需要借助第三方(如QQ)来完成认证的完整过程。

核心参与者

  • 用户 (浏览器): 用户的设备。

  • 系统 A (应用/Service Provider): 用户想要访问的业务系统,例如 app.a.com。

  • SSO 服务器 (认证中心/Identity Provider): 统一的身份认证服务,例如 sso.com。

  • QQ (外部认证方/External IdP): 提供身份信息的第三方,例如 qq.com。

流程步骤

  1. 访问应用: 用户在浏览器中访问系统 A (app.a.com) 的受保护页面。系统 A 检查发现浏览器请求中没有自己的登录凭证(app_a_cookie),判定用户未登录。

  2. 重定向至 SSO: 系统 A 将用户的浏览器重定向到SSO服务器的登录地址,并在 URL 中附带自己的身份和期望的回调地址。

    • sso.com/login?service=https://app.a.com/callback
  3. SSO 检查并再次重定向: SSO 服务器检查发现浏览器请求中也没有 SSO 的全局登录凭证(sso_cookie),判定用户在认证中心也未登录。此时,SSO 服务器呈现一个登录页面,上面有多种登录选项(如:用户名密码、使用 QQ 登录)。

  4. 用户选择 QQ 登录: 用户点击“使用 QQ 登录”按钮。SSO 服务器将浏览器重定向到 QQ 的授权页面,并附带自己的客户端 ID 和预设的回调地址(指向 SSO 自己)。

    • qq.com/auth?client_id=sso_client_id&redirect_uri=https://sso.com/auth/qq/callback
  5. QQ 认证与授权: 用户在 QQ 的页面上输入账号密码,并同意授权 SSO 服务器获取其基本信息。

  6. QQ 回调 SSO: QQ 认证成功后,将浏览器重定向回第4步中指定的 SSO 回调地址,并在 URL 中附带一个一次性的authorization_code

    • sso.com/auth/qq/callback?code=qq_auth_code_123
  7. SSO后台换取用户信息: SSO服务器的后端收到authorization_code后,向QQ的API服务器发起一个安全的后台HTTP请求,用code换取access_token,再用access_token获取用户的唯一标识(openid)等信息。

  8. SSO 建立全局会话: SSO 服务器确认了用户的身份,此时:

    • 在 SSO 域 (sso.com) 下为浏览器种下全局登录凭证(sso_cookie)。
    • 这标志着用户已经在单点登录系统中完成了认证。
  9. SSO 携带 Ticket 重定向回系统 A:SSO 服务器现在将浏览器重定向回系统 A 在第2步中提供的回调地址,并在URL中附带一个一次性票据 (Ticket)。在现实中还会有多余的一步,首先返回一个 uid列表(如果一个qq下有多个 uid的话),让用户选择 uid 以后,再根据 uid 返回一个独一无二的 ticketA(只绑定 uid)。

    • app.a.com/callback?ticket=ST-xyz-789
  10. 系统 A 后台验证 Ticket: 系统 A 的后端收到ticket后,向SSO服务器的验证接口发起一个安全的后台HTTP请求来核销这个ticket

  11. SSO 验证并返回用户信息: SSO 服务器验证ticket有效(且是发给系统A的),然后立即销毁该ticket防止重放攻击。验证成功后,向系统 A 返回该用户的身份信息(如统一的uidrole等)。

  12. 系统 A 建立本地会话: 系统 A 收到了确认信息,信任了该用户。它会在自己的域 (app.a.com) 下为浏览器种下应用自身的登录凭证(app_a_cookie),并最终将用户带到他最初想访问的页面。

至此,登录流程完成。如果用户再去访问已接入 SSO 的系统 B,流程会大大简化:他会被重定向到 SSO(步骤2),SSO 检测到sso_cookie已存在(步骤8),会直接跳过登录和QQ认证,立即执行步骤9,发放一个新票据给系统B,实现无感知的“单点登录”。

1
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
用户/浏览器         系统A              SSO服务器           QQ认证服务器         系统A资源服务
| | | | |
|-- 访问系统A -->| | | |
| | | | |
| |-- 重定向到SSO ---->| | |
| | /login?service= | | |
| | app.a.com/callback| | |
| | | | |
| | |-- 检查sso_cookie -->| |
| | | (未登录) | |
| | | | |
|<---------------|<------------------ 展示登录选项页面 ----| |
| | | | |
|-- 选择QQ登录 --|------------------>| | |
| | | | |
| | |-- 重定向到QQ认证 ->| |
| | | /authorize? | |
| | | client_id=sso_id& | |
| | | redirect_uri= | |
| | | sso.com/callback | |
| | | | |
|<---------------|<------------------|<-------------------| |
| | | | |
|-- QQ登录认证 --|-------------------|------------------>| |
| (用户名密码) | | | |
| | | | |
| | |<-- 授权码 code ----| |
| | | | |
| | |-- 后台交换token -->| |
| | | | |
| | |<-- 返回用户信息 ---| |
| | | | |
| | |-- 创建全局会话 --->| |
| | | (设置sso_cookie) | |
| | | | |
|<---------------|<-- 重定向回系统A --| | |
| | 携带ticket | | |
| | | | |
| |-- 验证ticket ----->| | |
| | | | |
| |<-- 返回用户信息 ---| | |
| | | | |
|<-- 设置本地 ---| | | |
| 会话cookie | | | |

设计思想

  1. 每个系统都是靠本域下的 cookie 来省却登录流程,如果无 cookie,要先确定是不是本系统作为最初授权源。如果不是,去找授权源。

  2. 授权源完成授权是用户手动操作,如果完成以后,向授权请求方给出一个一次性令牌( ticket/access_token),再通过 query params 之类的方案回调。

  3. 授权请求方得到一次性令牌后,再通过自身的鉴权机制拿这个一次性去授权源验证,获取令牌带有的长效信息(如用户的 OpenId)。

  4. 授权请求方把长效信息和一个本地令牌(会话id/sso_token)关联起来,然后把长效信息存在缓存里,或者每次验证本地令牌时去询问授权源。

  5. 本地令牌给 api 使用方,使用方每次来访问本系统,如果有 cookie 都直接验证账号和 token 的关联性,直接进入下一步,否则就经浏览器跳转到授权源。

  6. 授权源的拓扑关系大概是:user -> gateway (本地令牌提供方) -> sso 中心 -> 真授权方:

    • 如果一个页面有 gateway 的 cookie,则请求直接放行
    • 如果没有 gateway 的 cookie,有 sso 的 cookie,则 sso 不需要登录,带着 ticket 跳回 gateway
    • 否则执行登录流程
  7. 任何从没有 sso 登录过的新系统登录时,如从 A 到 B,B 会到达 SSO,直接触发 SSO 的 cookie 的免登录通过。

  8. 跳转都是经过浏览器实现的,多层调用有逐层嵌套和解套回调:

    • 尽量从 Referer 头获取回调地址。

    • 回调地址里面要尽量去掉 sso_ticket 这种sso 回调才会添加的附加参数,避免触发一次性凭证的校验机制。

安全问题

如果 token 或者 ticket 被复制、分享或者窃听拿来用,应该怎么防御水平越权
  1. 保证这些明文不被挪用的机制有:

    • 验证凭证要使用加密协议,而且 caller 要单独向 callee 鉴权。

    • ticket 和 token 最好在授权源和账号、回调 uri 绑定,不单独使用,和某些明 principal id 一起混合使用。

      • 如果从多个区域(header、body和 query param)都能获取账号与 sso 校验,而数据库写入只从 body 取值,这就产生了一种可以窃取 request 的其他部分,但是修改 body 到其他值的水平越权问题。解法是:统一从一个地方-body来获取账号值。
水平权限兼容问题

如我有1个token可以关联账号1和2,但不允许访问3。那么怎样让1和2可以互相切换,但是禁止切换3,就是这个体系的边界要考虑的问题了。

死循环

已存在的 token 解析出一个登录账号,然后重定向到 sso 服务,因为 sso 域下的 cookie 已存在,又会重定向回这个登录账号在当前账号下的页面。即/9/account已经登录了,浏览器输入了一个/10/account,则需要靠重定向到 sso,再重定向回来/9/account。如果给 sso 的重定向uri是/10/account则可能产生无限循环。

解法
解析出一个不会循环的地址

尝试给出一个和 sso_token 匹配的回调地址再登录,比如当前错误地从/9/account跳到/10/account,如果能够解析出9来,就不会产生循环。有些 sso 系统不支持。因为 cookie 可能账户绑定关系是一对多的,没人知道具体原来使用了哪个账户(可能一个 token绑定了7、8、9)。

选择账号列表法

如果已经有了正确的 cookie 跨了错误的账号(no permission on this account问题):

  • 回到 sso 中心的账号选择列表,用新的账号,跳到当前网站的主页。即用户可以使用 1、2,但是误跳到了3,重新回到账号选择界面,让它在 1 和 2 里选择。
重登陆法

本 token 本身已经不合法:

  • 登出:一次性清除全部的 cookie-当前登录系统的 cookie + 所有能通知到的系统的 cookie。

    • 一个完整清除 cookie 的 header 是:Set-Cookie: sso_token=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Domain=.example.com; Path=/。如果原 cookie 没有设置 domain,这里也不设置 domain,用的就是当前的 host 全地址(不包括所有二级域名),但是 path 即使是/也是必须的
  • 带有 status code == 0,加 data.code != 0 + location header 回 sso 服务。

SSO登出流程 (Logout)

SSO的核心理念是“一次登录,处处通行”。相应地,登出的核心理念应该是“一次登出,处处失效”,这被称为单点登出 (Single Logout, SLO)

如果只清除了当前应用(系统 A )的登录状态,而 SSO 认证中心的全局会话依然存在,那么用户刷新页面后,系统 A 会再次将他重定向到 SSO,SSO 发现全局会话还在,会立刻又把他自动登录回系统 A。这就导致了用户无法真正登出的尴尬局面。

因此,正确的登出流程必须由 SSO 认证中心来协调。也因此,中间服务不要缓存 sso_token,因为不一定能感知到这个 sso_token 被他处 invalidate 了。

流程步骤:

  1. 用户发起登出: 用户在系统A的界面上点击“登出”按钮。

  2. 系统 A 清理本地会话: 系统 A 的后端接收到登出请求后,首先执行本地登出操作。这包括:

    • 销毁系统 A 自身的会话 (Session)。
    • 清除种在app.a.com域下的本地登录凭证(app_a_cookie)-这一步需要重新使用 Set-Cookie header 设置一个过期的 cookie
  3. 重定向至 SSO 进行全局登出: 清理完本地会话后,系统 A 必须将用户的浏览器重定向到 SSO 服务器的登出地址。通常还会附带一个参数,告诉 SSO 登出后应该跳转回哪里。注意这个参数是靠重放 cookie 而不是携带参数来实现的。

    • sso.com/logout?post_logout_redirect_uri=https://app.a.com/login
  4. SSO 执行全局登出: SSO 服务器接收到请求后,执行全局登出操作:

    • 销毁 SSO 的中央认证会话。
    • 清除种在sso.com域下的全局登录凭证(sso_cookie)。
  5. SSO 通知所有相关应用登出 (关键步骤): 这是实现单点登出 (SLO) 的核心。SSO 服务器会查找在此次全局会话期间,该用户都登录了哪些应用(例如系统A、系统B、系统C)。然后,SSO服务器会从后端向所有这些应用的预设登出接口发送登出通知 (Logout Notification)。

    • 这个通知通常是一个包含了logout_token的HTTP POST请求,应用收到后需要验证这个token的合法性。
  6. 其他应用清理本地会话: 系统 B、系统 C 等其他应用收到来自 SSO 的登出通知后,执行与步骤2相同的本地登出操作,清理各自的会话和 Cookie。

  7. 最终跳转: 在 SSO 服务器完成所有登出操作后,它会将用户的浏览器重定向到第3步中指定的post_logout_redirect_uri地址,通常是应用的登录页面。用户此时会看到系统 A 的登录页,表示已成功登出。

至此,用户在整个 SSO 体系中的所有会话均已失效,实现了彻底的“单点登出”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
用户/浏览器         系统A              SSO服务器           QQ认证服务器         系统A资源服务
| | | | |
|-- 请求登出 --->| | | |
| | | | |
| |-- 重定向到SSO ---->| | |
| | /logout | | |
| | | | |
| | |-- 清除全局会话 --->| |
| | | | |
| | |-- 通知QQ登出 ---->| |
| | | | |
| |<-- 清除本地会话 ---| | |
| | | | |
|<-- 重定向到 ---| | | |
| 登录页面 | | | |

OAUTH2

基本角色定义

  1. 授权服务器 - 验证身份并颁发令牌的服务器(我上面代码示例中的 OAuth 服务器)

  2. 资源服务器 - 提供 API 资源的服务器

  3. 客户端应用 - 访问 API 的应用,可以是:

    • 第一方应用 - 您公司自己的应用

    • 第二方应用 - 业务合作伙伴的应用

    • 第三方应用 - 外部开发者的应用

token 类型

  • access_token (默认有效期 24 小时)

  • refresh_token(默认有效期 30 自然日,每次刷新 access_token 的操作可自动刷新 refresh_token 有效期的起始计算时间)。

客户端凭证流程(适用于服务器到服务器通信)

1
2
3
4
5
6
7
8
9
10
第一方/第二方应用                    授权服务器                  资源服务器(API)
| | |
|---1. 请求访问令牌------------>| |
| (使用client_id和client_secret)| |
| | |
|<--2. 返回access_token---------| |
| | |
|---3. 使用access_token调用API----------------------------->|
| | |
|<--4. 返回API响应------------------------------------------|

这种流程中:

  • 获取令牌方: 第一方或第二方应用直接获取令牌
  • 使用令牌方: 同样的应用使用令牌调用API
  • 无用户参与: 整个过程无需最终用户授权
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
客户端                                认证服务器
| |
|------- POST /token ---------------->|
| grant_type=client_credentials |
| client_id=CLIENT_ID |
| client_secret=CLIENT_SECRET |
| |
|<------ 200 OK JSON 响应 ------------|
| { |
| "access_token": "eyJhbGc...", |
| "token_type": "Bearer", |
| "expires_in": 3600 |
| } |
| |
|------- 使用令牌访问API ------------>|
| Authorization: Bearer eyJhbGc... |
| |

授权码流程(适用于有用户参与的场景)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
用户          第一方/第二方/第三方应用          授权服务器          资源服务器(API)
| | | |
|----1. 访问应用---->| | |
| | | |
|<---2. 重定向到-----| | |
| 授权服务器 | | |
| | | |
|----3. 登录并授权------------------------------>| |
| | | |
|<---4. 重定向回应用,携带授权码(code)-----------| |
| | | |
|----5. 返回应用,-->| | |
| 带上授权码 | | |
| | | |
| |---6. 使用code请求令牌---->| |
| | (附带client_id/secret) | |
| | | |
| |<--7. 返回access_token-----| |
| | 和refresh_token | |
| | | |
| |---8. 使用access_token调用API------------------>|
| | | |
| |<--9. 返回API响应-------------------------------|
| | | |

这里面有两次重定向:

第一次:重定向到授权服务器。用户是带着一个 param 去访问 authserver 的,在我们常见的场景里线索通跳到 sso 是第一次重定向,参数里带有 https://app.example.com/callback 就是授权完以后再回到线索通的一个 url。

  • 当用户尝试访问需要授权的功能时,应用生成一个授权请求 URL

  • 这个 URL 包含 redirect_uri 参数(应用事先在授权服务器注册的 URL)

  • 应用通过 HTTP 302重定向或者前端跳转,将用户浏览器导向授权服务器

1
2
3
4
5
6
GET https://auth-server.com/oauth/authorize?
response_type=code&
client_id=CLIENT_ID&
redirect_uri=https://app.example.com/callback&
scope=profile email&
state=random_state_value

第二次:重定向回应用,携带授权码(code)。

  • 用户在授权服务器完成身份验证并授予权限后

  • 授权服务器使用之前请求中的redirect_uri,将用户浏览器重定向回应用

  • 重定向 URL 中附加授权码(code)作为查询参数

1
2
3
4
HTTP/1.1 302 Found
Location: https://app.example.com/callback?
code=AUTHORIZATION_CODE_VALUE&
state=random_state_value
  • 授权服务器只会重定向到预先注册的 URI,防止授权码被重定向到恶意网站
  • 防止授权码被重定向到恶意网站
1
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
用户/浏览器         客户端应用            认证服务器              资源服务器
| | | |
| | | |
| |-- 重定向用户认证 ->| |
| | /authorize? | |
| | client_id=...& | |
| | redirect_uri=...& | |
| | response_type=code| |
| | | |
|<-----------------|<-------------------| |
| | | |
|-- 登录界面交互 ->| | |
| | | |
| |<-- 重定向回客户端 -| |
|<-- 重定向回 -----| /callback?code=.. | |
| 客户端应用 | | |
| | | |
| |-- POST /token ---->| |
| | grant_type= | |
| | authorization_code| |
| | &code=... | |
| | &redirect_uri=... | |
| | &client_id=... | |
| | &client_secret=...| |
| | | |
| |<-- 返回令牌 -------| |
| | access_token, | |
| | refresh_token等 | |
| | | |
| |-------------------- 使用令牌访问资源 ----->|
| | Authorization: | |
| | Bearer {token} | |
| | | |
| |<------------------- 返回受保护资源数据 ----|
| | | |

刷新令牌流程 (Refresh Token Flow)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
客户端                                认证服务器
| |
|------- POST /token ---------------->|
| grant_type=refresh_token |
| refresh_token=REFRESH_TOKEN |
| client_id=CLIENT_ID |
| client_secret=CLIENT_SECRET |
| |
|<------ 200 OK JSON 响应 ------------|
| { |
| "access_token": "NEW_TOKEN", |
| "token_type": "Bearer", |
| "expires_in": 3600, |
| "refresh_token": "NEW_REFRESH" |
| } |
| |