飞书OAuth登录跨域Cookie方案探索与实践
一次前后端分离架构下,从困惑到清晰的完整技术探索历程
一、背景
业务需求
我们的智能平台需要集成飞书OAuth2.0登录,实现统一身份认证。系统采用前后端分离架构:
- 前端:React单页应用,部署在独立域名
- 后端:Spring Boot REST API,部署在另一个域名/IP
- 认证方式:OAuth2.0 + Cookie传递token
- 核心挑战:前后端跨域,如何让Cookie正常工作?
初始架构
| 环境 | 前端地址 | 后端地址 |
|---|---|---|
| SIT(本地开发) | localhost:3000 | localhost:9080 |
| UAT(测试环境) | mvpfont-uat.example.com | UAT后端IP:9080 |
| PROD(生产环境) | mvpfont.example.com | PROD后端IP:9080 |
二、第一个困惑:URL重定向异常
问题现象
飞书授权成功后,用户应该被重定向到前端首页,结果却出现了异常路径:
- 期望:
http://UAT后端IP:3000/dashboard - 实际:
http://localhost:9080/feishu/UAT后端IP:3000/dashboardURL被当作相对路径拼接到了当前请求路径上!
最初的配置文件
yaml
# application.yml
frontURL:
sitURL: localhost:3000/dashboard # 缺少协议
uatURL: UAT后端IP:3000/dashboard # 缺少协议
prodURL: PROD后端IP:3000/dashboard # 缺少协议
问题根源
response.sendRedirect()的行为:
- 绝对URL(含
http://或https://):直接重定向到该地址 - 相对路径(不含协议):拼接到当前请求的完整路径上
我们的URL缺少协议前缀,被识别为相对路径:
- 当前请求:
http://localhost:9080/feishu/callback - 重定向参数:
UAT后端IP:3000/dashboard - 实际结果:
http://localhost:9080/feishu/UAT后端IP:3000/dashboard
解决方案
- 完善配置文件:
bash
frontURL:
sitURL: http://localhost:3000/dashboard
uatURL: https://mvpfont-uat.example.com/dashboard
prodURL: https://mvpfont.example.com/dashboard
- 添加防御性代码:
ini
String frontendUrl = feishuLoginService.getFrontEndURL();
if (frontendUrl != null && !frontendUrl.startsWith("http://") && !frontendUrl.startsWith("https://")) {
frontendUrl = "http://" + frontendUrl;
logger.warn("前端URL缺少协议前缀,已自动添加: {}", frontendUrl);
}
response.sendRedirect(frontendUrl);
经验总结
重定向URL务必包含完整的协议前缀(http://或 https://),否则会被当作相对路径处理。
三、第二个困惑:前端读取不到Cookie
问题现象
后端成功设置了Cookie,但前端JavaScript读取不到:
javascript
const token = document.cookie.split('; ').find(row => row.startsWith('access_token='));
console.log(token); // undefined
浏览器开发者工具显示Cookie根本没有被保存!
跨域Cookie的三座大山
-
SameSite属性:
- 浏览器默认SameSite=Lax,跨站点请求被拒绝
- 控制台警告:
Cookie "access_token" has been rejected because it is in a cross-site context
-
Secure标志:
- 要设置
SameSite=None,必须同时设置Secure标志 - HTTP环境下
SameSite=None会被浏览器忽略
- 要设置
-
Domain属性:
- 跨域场景下,Domain必须正确设置
- 后端域名
UAT后端IP:9080与前端域名mvpfont-uat.example.com完全不同
解决方案:多环境配置 + 智能Domain提取
- 配置文件支持多环境:
yaml
spring:
profiles:
active: uat # sit / uat / prod
frontURL:
sitURL: http://localhost:3000/dashboard
uatURL: https://mvpfont-uat.example.com/dashboard
prodURL: https://mvpfont.example.com/dashboard
backendURL:
sitURL: http://localhost:9080
uatURL: http://UAT后端IP:9080
prodURL: http://PROD后端IP:9080
- 智能提取顶级域名:
typescript
private String extractTopLevelDomain(String url) {
if (url == null || url.isEmpty()) return null;
try {
String host = url;
if (host.contains("://")) host = host.substring(host.indexOf("://") + 3);
if (host.contains(":")) host = host.substring(0, host.indexOf(":"));
if (host.contains("/")) host = host.substring(0, host.indexOf("/"));
if ("localhost".equals(host) || host.matches("^\d+\.\d+\.\d+\.\d+$")) {
return null; // localhost或IP不设置Domain
}
String[] parts = host.split("\.");
if (parts.length >= 2) {
return "." + parts[parts.length - 2] + "." + parts[parts.length - 1];
}
return null;
} catch (Exception e) {
logger.error("提取顶级域名失败: {}", url, e);
return null;
}
}
- 手动构建跨域Cookie:
typescript
private void addCrossDomainCookie(HttpServletResponse response, String name, String value,
String path, int maxAge, boolean secure, String domain) {
StringBuilder cookieHeader = new StringBuilder();
cookieHeader.append(name).append("=").append(value);
cookieHeader.append("; Path=").append(path);
cookieHeader.append("; Max-Age=").append(maxAge);
if (domain != null && !domain.isEmpty()) {
cookieHeader.append("; Domain=").append(domain);
}
if (secure) {
cookieHeader.append("; Secure");
cookieHeader.append("; SameSite=None");
} else {
cookieHeader.append("; SameSite=Lax");
}
response.addHeader("Set-Cookie", cookieHeader.toString());
}
经验总结
跨域Cookie三要素:
- Domain:设置为共同的顶级域名(如
.example.com) - SameSite=None:允许跨站点传递
- Secure:必须HTTPS(SameSite=None的前提)
四、第三个困惑:HTTPS连HTTP报错
问题现象
修改配置使用HTTPS后,启动服务访问时出现错误:
sql
java.lang.IllegalArgumentException: Invalid character found in method name [0x160x030x010x07...]
日志显示一堆十六进制数据!
问题分析
- 配置声称使用HTTPS(
https://localhost:9080) - 服务实际运行HTTP(port 9080 (http))
- 浏览器尝试建立TLS连接,发送TLS握手数据
- HTTP服务器无法解析二进制TLS数据,报错!
核心经验:HTTPS不能用纯IP
- IP地址无法申请SSL证书
- 自签名证书浏览器不信任
解决方案:环境差异化配置
yaml
backendURL:
sitURL: http://localhost:9080 # 本地开发,HTTP
uatURL: http://UAT后端IP:9080 # 内网IP,HTTP(内网不需要HTTPS)
prodURL: http://PROD后端IP:9080 # 内网IP,HTTP(由网关处理HTTPS)
架构说明
SIT环境(本地开发):
- 浏览器 → HTTP → localhost:3000 (前端) → HTTP → localhost:9080 (后端)
- 同域名(localhost),Cookie使用SameSite=Lax,无需HTTPS
UAT/PROD环境(生产环境):
- 浏览器 → HTTPS → Nginx (mvpfont-uat.example.com) → HTTP → UAT后端IP:9080 (后端)
- 前端使用HTTPS域名,后端使用HTTP内网IP
- Cookie使用Domain=.example.com + SameSite=None + Secure
经验总结
HTTPS使用原则:
- 本地开发:HTTP足够,简单快速
- 跨域场景:前端必须HTTPS + 域名
- 内网服务:可以用HTTP + IP,由网关处理SSL
- IP地址无法使用HTTPS,必须用域名
五、最终方案总结
配置文件(application.yml)
yaml
spring:
profiles:
active: uat
frontURL:
sitURL: http://localhost:3000/dashboard
uatURL: https://mvpfont-uat.example.com/dashboard
prodURL: https://mvpfont.example.com/dashboard
backendURL:
sitURL: http://localhost:9080
uatURL: http://UAT后端IP:9080
prodURL: http://PROD后端IP:9080
环境对比表
| 配置项 | SIT | UAT | PROD |
|---|---|---|---|
| 前端协议 | HTTP | HTTPS | HTTPS |
| 前端地址 | localhost:3000 | mvpfont-uat.example.com | mvpfont.example.com |
| 后端协议 | HTTP | HTTP | HTTP |
| 后端地址 | localhost:9080 | UAT后端IP:9080 | PROD后端IP:9080 |
| Cookie Domain | 不设置 | .example.com | .example.com |
| Cookie SameSite | Lax | None | None |
| Cookie Secure | 否 | 是 | 是 |
| 是否跨域 | 否 | 是 | 是 |
六、核心经验总结
-
URL重定向:
- 务必包含完整协议(
http://或https://) - 添加防御性检查,自动补全协议
- 务必包含完整协议(
-
多环境配置:
- YAML中配置sit/uat/prod三套URL
- 通过
spring.profiles.active切换环境 - Service层根据环境动态获取URL
-
跨域Cookie:
- Domain:从前端URL提取顶级域名(如
.example.com) - SameSite=None:允许跨站点传递(需要HTTPS)
- Secure:必须HTTPS(SameSite=None的前提)
- 清除旧Cookie:避免出现多个同名但Domain不同的Cookie
- Domain:从前端URL提取顶级域名(如
-
HTTPS与IP:
- localhost可用HTTP
- 跨域必须HTTPS + 域名
- IP地址不能用HTTPS
- 内网服务可用HTTP,由网关处理SSL
七、总结
这次技术探索,我们从最初的困惑:
- URL重定向去哪儿了?
- 为什么Cookie前端读不到?
- HTTPS连HTTP为什么报错?
到最终形成完整的解决方案:
- 多环境配置 + 动态URL获取
- 智能Domain提取 + 跨域Cookie
- 环境差异化的协议选择
最大的收获:
- 重定向URL必须包含协议
- 跨域Cookie的Domain是关键
- IP地址不能用HTTPS
- 环境差异化配置很重要
- 清除旧Cookie避免冲突