SSO 简单介绍
SSO(Single Sign-On,单点登录)是一种身份认证过程,它允许用户使用一组凭证(如用户名和密码)登录到多个相关但是技术上独立的系统。一旦用户被认证,他们就可以访问所有使用SSO服务的应用程序或服务,而无需为每个系统重新输入凭证。
在实习的时候就思考过这个服务的实现方案,现在有时间了就打算了解一下技术方案和实现。这篇博客就是我自己思考和实践的过程。
思考过程
首先从业务的视角去思考这个服务的架构,虽然业务逻辑视角上SSO服务是鉴权和认证的身份提供者(Identity Provider, IdP),而其他资源服务是服务的服务提供者(Service Provider, SP),SSO供足够信任的权限凭证,资源服务才能对用户提供服务或返回资源,业务上资源服务是依赖 SSO 服务的。
但是设计上,服务提供者分别是自己的业务线,自己的运行逻辑,甚至有自己的内部的权限管理系统,这个时候 SSO 应该以一种低侵入的设计方式能过丝滑接入所有资源服务,对服务的业务入侵要尽可能地少,这是对整个实现方案的一个前置视角。
功能思考
接下来是网上搜集的关于 SSO 应该(或许)提供的功能:
- ☑ 同域名下的单点登录登出
- ☐ 权限管理,这个我暂时有比较不一样的意见。
- ☐ 跨域登录,或者跨域 SSO,本质上其实和同域名下的 SSO 是一样的实现思路,
SSO 的权限管理这个功能,我实际上是有不一样的想法的,资源服务的鉴权管理功能不应该交给 SSO 服务,或者说鉴权管理服务应该是另一个专注于权限管理的微服务,而不应该是 SSO 该干的事,下面是我给出的理由。
如果 SSO 只提供登录会话的管理功能,也就是只管理用户的管理状态,那么整个系统的架构应该是:用户在 SSO 注册,并且受 SSO 管理登录会话状态。其他所有需要依赖用户登录状态的资源服务接入 SSO 服务,向 SSO 询问用户的登录状态,同时 SSO 服务并不需要存储或者记忆任何有关资源服务的信息。
对于权限管理,权限管理本质由三个部分组成:用户,资源,行为,鉴权的过程本质是判断用户对资源的行为是否允许,基于这个视角,权限管理的方案可以分为两种,ABAC(Attribute-Based Access Control)基于属性的访问控制,和RBAC(Role-Based Access Control)基于角色的访问控制,后续会详细介绍这两种方案。
于是我们可以得出的结论是,权限管理的逻辑本质是:资源和行为限制角色或者属性,用户作为角色或者具备属性,比对两者得出是否允许。访问控制涉及三个方面的要求,而SSO本质只对用户的登录会话状态负责,所以权限管理本不应该耦合到 SSO 登录会话管理服务里面。
简单的例子就是:资源服务提供者拉黑一个用户,并不需要通知 SSO,SSO 只关心用户是否在线就行。
在 ABAC 和 RBAC 中,SSO 更像是一个角色的提供者(在线用户角色)或者属性的认证方(在线属性),而资源服务应该根据这些做更细致和具体的权限管理。
从这个视角来看,其实权限管理服务本质上是一个角色或属性的管理平台,资源注册行为对应的角色或者属性,用户也在管理平台认领这些角色或者属性,然后判断是否同访问。
这是两者的边界,SSO 只对用户的在线属性负责,鉴权服务同时对资源,行为和用户三方负责。
所以这样设计或许更加合理的:
用户发起对资源的请求
= = = = 》资源服务询问权限管理服务是否鉴权通过
= = = = 》鉴权服务发现需要得知用户的在线状态
= = = = 》访问 SSO 服务获取在线状态(认证在线角色或者在线属性)
= = = = 》完成鉴权
关于跨域认证,这个其实更多涉及到跨域资源获取,可以采用 OAuth2.0 协议完成,所以这个也算不上是SSO的功能范畴,但是同时也借鉴了 OAuth2.0 的思想。
接口设计
- SSO-Server
- SSO-client
- Resource-Server
Restful-API
SSO-Server
登录界面: /sso/login?original_url=[URL编码]
默认登录成功界面:/sso/success
登录接口: /sso/api
接口描述 | URI | 方法 | Request \ Response |
---|---|---|---|
登录接口 | /login | POST | Request : userId, password, originalUrl Response : 设置 cookie 和重定向 |
登录校验接口 | /validate | GET | Request : @Nullable acc_token Response : valid, updateToken |
登出接口 | /logout | POST | Request : 无 Response : 删除 Cookie |
Token 续期接口 | /renewal | POST | Request:updateToken Response : 更新 cookie |
SSO-client SSO
Spring MVC 拦截器 TokenParseInterceptor 避免对业务的侵入
暂时的实现方案是将登录信息,也就是用户名放入 TTL 中作为请求的上下文,没有上下文至少表示这次请求不是在线用户发起的。
调用验证接口的方案暂时用的 HttpClient,为什么不同 RPC 是因为 RPC 存在服务之间的依赖关系,想了想感觉还是不太合适。
@Configuration
public class WebInterceptorConfig implements WebMvcConfigurer {
@Value("${sso.validate-location}")
private String ssoServer;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new TokenParseInterceptor(ssoServer));
}
}
Resource-Server
资源界面:/user.html
资源API 基础路径:/user/api
- [GET] /info 获取基础信息接口
JWT
标准结构示例:
{
"iss" : "Issuer(发行人):表示JWT的发行者。",
"exp" : "Expiration Time(过期时间):表示JWT的过期时间。",
"sub" : "Subject(主题):通常指代JWT的主体,例如用户的ID。",
"aud" : "Audience(受众):表示JWT的预期接收者。",
"nbf" : "Not Before(生效时间):表示JWT在此时间之前不可用。",
"iat" : "Issued At(签发时间):表示JWT的签发时间。",
"jti" : "JWT ID(JWT的唯一标识符):JWT的唯一标识符。"
}
在这个 Demo 中 token 中只需要 sub 和 exp,sub 存储 UserID,exp 存储过期时间。
代码流程
SSO 未登录时单点登录流程(默认采用 Cookie 登录的方式)
SSO 令牌续期流程
要点思考
SSO 的高性能和高可用:
在访问量很少的时候,可以直接考虑单点负载,登录登出状态直接保存在缓存里就行,服务下线则用户下线
在访问量增加的时候,SSO 的性能瓶颈会在 JWT 解析和用户登录(涉及到数据库瓶颈)这两个功能上面,所以采用水平扩展然后 Redis 作为一致性缓存的方案即可。
JWT 的续期:
提供续期接口,SSO 客户端每次验权的时候,SSO 服务可以返回一个 JWT 续期的 Advice(随机码,有效时常5分钟),客户端可以自己选择是否在响应头里设置这个 Advice,拦截器可以直接在响应头里面设置,前端 js 拿到这个 advice 后再去请求续期接口,会更新 JWT。
CSRF 防护:
一般来说,可以使用这个方案:
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf(); // 启用CSRF保护
}
}
但是深入原理了解得知,Spring Security中的CSRF(跨站请求伪造)保护原理主要基于同步器令牌模式(Synchronizer Token Pattern),而这个随机字段是添加在 Session 里面的,对于单节点来说,这个当然没有上什么问题,但是,对于集群来说,问题就大了。
对于后端集群,我们通常采用 Nginx 作为反向代理,Nginx 的默认反向代理模式是轮流代理,也就是会导致前后两次访问路由不到同一个服务器上,Session 就没啥用了,这个时候还是得用 Redis 代替 Session 的功能。
所以其实最好的方案是把 token 塞到 header 里面,因为跨域伪造请求攻击的原理是浏览器默认会携带 cookie,但是自定义 header 字段却不能默认携带,攻击页面本身也无法拿到其他域名下的 cookie 字段。