15.0 微服务安全OAuth2&SSO&JWT
微服务安全OAuth2&SSO&JWT
一、OAuth2.0介绍
OAuth(Open Authorization)是一个关于授权(authorization)的开放网络标准,允许用户授权第三方应用访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方移动应用或分享他们数据的所有内容。OAuth在全世界得到广泛应用,目前的版本是2.0版。
OAuth协议:https://tools.ietf.org/html/rfc6749
协议特点:
- 简单:不管是OAuth服务提供者还是应用开发者,都很易于理解与使用;
- 安全:没有涉及到用户密钥等信息,更安全更灵活;
- 开放:任何服务提供商都可以实现OAuth,任何软件开发商都可以使用OAuth;
1.1 应用场景
- 原生app授权:app登录请求后台接口,为了安全认证,所有请求都带token信息,如果登录验证、请求后台数据。
- 前后端分离单页面应用:前后端分离框架,前端请求后台数据,需要进行oauth2安全认证,比如使用vue、react后者h5开发的app
- 第三方应用授权登录,比如QQ,微博,微信的授权登录。
有一个"云冲印"的网站,可以将用户储存在Google的照片,冲印出来。用户为了使用该服务,必须让"云冲印"读取自己储存在Google上的照片。只有得到用户的授权,Google才会同意"云冲印"读取这些照片。那么,"云冲印"怎样获得用户的授权呢?
传统方法是,用户将自己的Google用户名和密码,告诉"云冲印",后者就可以读取用户的照片了。这样的做法有以下几个严重的缺点:
- "云冲印"为了后续的服务,会保存用户的密码,这样很不安全。
- Google不得不部署密码登录,而我们知道,单纯的密码登录并不安全。
- "云冲印"拥有了获取用户储存在Google所有资料的权力,用户没法限制"云冲印"获得授权的范围和有效期。
- 用户只有修改密码,才能收回赋予"云冲印"的权力。但是这样做,会使得其他所有获得用户授权的第三方应用程序全部失效。
- 只要有一个第三方应用程序被破解,就会导致用户密码泄漏,以及所有被密码保护的数据泄漏。
生活中常见的oauth2场景,京东商城(https://www.jd.com/)接入微信开放平台,可以通过微信登录。
登录流程分析:
https://www.processon.com/view/link/60a32e7a079129157118740f
微信开发平台文档:
https://developers.weixin.qq.com/doc/oplatform/Mobile_App/WeChat_Login/Development_Guide.html
1.2 基本概念
(1)Third-party application:第三方应用程序,又称"客户端"(client),即例子中的"云冲印"。
(2)HTTP service:HTTP服务提供商,简称"服务提供商",即例子中的Google。
(3)Resource Owner:资源所有者,又称"用户"(user)。
(4)User Agent:用户代理,比如浏览器。
(5)Authorization server:授权服务器,即服务提供商专门用来处理认证授权的服务器。
(6)Resource server:资源服务器,即服务提供商存放用户生成的资源的服务器。它与授权服务器,可以是同一台服务器,也可以是不同的服务器。
OAuth的作用就是让"客户端"安全可控地获取"用户"的授权,与"服务提供商"进行交互。
1.3 优缺点
优点:
- 更安全,客户端不接触用户密码,服务器端更易集中保护
- 广泛传播并被持续采用
- 短寿命和封装的token
- 资源服务器和授权服务器解耦
- 集中式授权,简化客户端
- HTTP/JSON友好,易于请求和传递token
- 考虑多种客户端架构场景
- 客户可以具有不同的信任级别
缺点:
- 协议框架太宽泛,造成各种实现的兼容性和互操作性差
- 不是一个认证协议,本身并不能告诉你任何用户信息。
二、OAuth2的设计思路
OAuth在"客户端"与"服务提供商"之间,设置了一个授权层(authorization layer)。"客户端"不能直接登录"服务提供商",只能登录授权层,以此将用户与客户端区分开来。"客户端"登录授权层所用的令牌(token),与用户的密码不同。用户可以在登录的时候,指定授权层令牌的权限范围和有效期,"客户端"登录授权层以后,"服务提供商"根据令牌的权限范围和有效期,向"客户端"开放用户储存的资料。
OAuth 2.0的运行流程如下图,摘自RFC 6749:
(A)用户打开客户端以后,客户端要求用户给予授权。
(B)用户同意给予客户端授权。
(C)客户端使用上一步获得的授权,向授权服务器申请令牌。
(D)授权服务器对客户端进行认证以后,确认无误,同意发放令牌。
(E)客户端使用令牌,向资源服务器申请获取资源。
(F)资源服务器确认令牌无误,同意向客户端开放资源。
令牌(token)与密码(password)的作用是一样的,都可以进入系统,但是有三点差异。
(1)令牌是短期的,到期会自动失效,用户自己无法修改。密码一般长期有效,用户不修改,就不会发生变化。
(2)令牌可以被数据所有者撤销,会立即失效。密码一般不允许被他人撤销。
(3)令牌有权限范围(scope)。对于网络服务来说,只读令牌就比读写令牌更安全。密码一般是完整权限。
上面这些设计,保证了令牌既可以让第三方应用获得权限,同时又随时可控,不会危及系统安全。这就是 OAuth 2.0 的优点。
2.1 客户端授权模式
客户端必须得到用户的授权(authorization grant),才能获得令牌(access token)。OAuth 2.0 对于如何颁发令牌的细节,规定得非常详细。具体来说,一共分成四种授权类型(authorization grant),即四种颁发令牌的方式,适用于不同的互联网场景。
- 授权码模式(authorization code)
- 密码模式(resource owner password credentials)
- 简化(隐式)模式(implicit)
- 客户端模式(client credentials)
不管哪一种授权方式,第三方应用申请令牌之前,都必须先到系统备案,说明自己的身份,然后会拿到两个身份识别码:客户端 ID(client ID)和客户端密钥(client secret)。这是为了防止令牌被滥用,没有备案过的第三方应用,是不会拿到令牌的。
授权码模式
授权码(authorization code)方式,指的是第三方应用先申请一个授权码,然后再用该码获取令牌。
这种方式是最常用的流程,安全性也最高,它适用于那些有后端的 Web 应用。授权码通过前端传送,令牌则是储存在后端,而且所有与资源服务器的通信都在后端完成。这样的前后端分离,可以避免令牌泄漏。
适用场景:目前市面上主流的第三方验证都是采用这种模式
它的步骤如下:
(A)用户访问客户端,后者将前者导向授权服务器。
(B)用户选择是否给予客户端授权。
(C)假设用户给予授权,授权服务器将用户导向客户端事先指定的"重定向URI"(redirection URI),同时附上一个授权码。
(D)客户端收到授权码,附上早先的"重定向URI",向授权服务器申请令牌。这一步是在客户端的后台的服务器上完成的,对用户不可见。
(E)授权服务器核对了授权码和重定向URI,确认无误后,向客户端发送访问令牌(access token)和更新令牌(refresh token)。
- A网站提供一个链接,用户点击后就会跳转到 B 网站,授权用户数据给 A 网站使用。下面就是 A 网站跳转 B 网站的一个示意链接。
> https://b.com/oauth/authorize? > response_type=code& #要求返回授权码(code) > client_id=CLIENT_ID& #让 B 知道是谁在请求 > redirect_uri=CALLBACK_URL& #B 接受或拒绝请求后的跳转网址 > scope=read # 要求的授权范围(这里是只读) >
客户端申请授权的URI,包含以下参数:
- response_type:表示授权类型,必选项,此处的值固定为"code"
- client_id:表示客户端的ID,必选项
- redirect_uri:表示重定向URI,可选项
- scope:表示申请的权限范围,可选项
- state:表示客户端的当前状态,可以指定任意值,授权服务器会原封不动地返回这个值。
- 用户跳转后,B 网站会要求用户登录,然后询问是否同意给予 A 网站授权。用户表示同意,这时 B 网站就会跳回redirect_uri参数指定的网址。跳转时,会传回一个授权码,就像下面这样。
> https://a.com/callback?code=AUTHORIZATION_CODE #code参数就是授权码
- A 网站拿到授权码以后,就可以在后端,向 B 网站请求令牌。 用户不可见,服务端行为
>https://b.com/oauth/token? > client_id=CLIENT_ID& > client_secret=CLIENT_SECRET& # client_id和client_secret用来让 B 确认 A 的身份,client_secret参数是保密的,因此只能在后端发请求 > grant_type=authorization_code& # 采用的授权方式是授权码 > code=AUTHORIZATION_CODE& # 上一步拿到的授权码 > redirect_uri=CALLBACK_URL # 令牌颁发后的回调网址
- B 网站收到请求以后,就会颁发令牌。具体做法是向redirect_uri指定的网址,发送一段 JSON 数据。
> { > "access_token":"ACCESS_TOKEN", # 令牌 > "token_type":"bearer", > "expires_in":2592000, > "refresh_token":"REFRESH_TOKEN", > "scope":"read", > "uid":100101, > "info":{...} > }
简化(隐式)模式
有些 Web 应用是纯前端应用,没有后端。这时就不能用上面的方式了,必须将令牌储存在前端。RFC 6749 就规定了第二种方式,允许直接向前端颁发令牌,这种方式没有授权码这个中间步骤,所以称为(授权码)"隐藏式"(implicit)
简化模式不通过第三方应用程序的服务器,直接在浏览器中向授权服务器申请令牌,跳过了"授权码"这个步骤,所有步骤在浏览器中完成,令牌对访问者是可见的,且客户端不需要认证。
这种方式把令牌直接传给前端,是很不安全的。因此,只能用于一些安全要求不高的场景,并且令牌的有效期必须非常短,通常就是会话期间(session)有效,浏览器关掉,令牌就失效了。
它的步骤如下:
(A)客户端将用户导向授权服务器。
(B)用户决定是否给于客户端授权。
(C)假设用户给予授权,授权服务器将用户导向客户端指定的"重定向URI",并在URI的Hash部分包含了访问令牌。
(D)浏览器向资源服务器发出请求,其中不包括上一步收到的Hash值。
(E)资源服务器返回一个网页,其中包含的代码可以获取Hash值中的令牌。
(F)浏览器执行上一步获得的脚本,提取出令牌。
(G)浏览器将令牌发给客户端。
- A 网站提供一个链接,要求用户跳转到 B 网站,授权用户数据给 A 网站使用。
> https://b.com/oauth/authorize? > response_type=token& # response_type参数为token,表示要求直接返回令牌 > client_id=CLIENT_ID& > redirect_uri=CALLBACK_URL& > scope=read >
- 用户跳转到 B 网站,登录后同意给予 A 网站授权。这时,B 网站就会跳回redirect_uri参数指定的跳转网址,并且把令牌作为 URL 参数,传给 A 网站。
> https://a.com/callback#token=ACCESS_TOKEN #token参数就是令牌,A 网站直接在前端拿到令牌。
密码模式
如果你高度信任某个应用,RFC 6749 也允许用户把用户名和密码,直接告诉该应用。该应用就使用你的密码,申请令牌,这种方式称为"密码式"(password)。
在这种模式中,用户必须把自己的密码给客户端,但是客户端不得储存密码。这通常用在用户对客户端高度信任的情况下,比如客户端是操作系统的一部分,或者由一个著名公司出品。而授权服务器只有在其他授权模式无法执行的情况下,才能考虑使用这种模式。
适用场景:自家公司搭建的授权服务器
它的步骤如下:
(A)用户向客户端提供用户名和密码。
(B)客户端将用户名和密码发给授权服务器,向后者请求令牌。
(C)授权服务器确认无误后,向客户端提供访问令牌。
- A 网站要求用户提供 B 网站的用户名和密码,拿到以后,A 就直接向 B 请求令牌。整个过程中,客户端不得保存用户的密码。
> https://oauth.b.com/token? > grant_type=password& # 授权方式是"密码式" > username=USERNAME& > password=PASSWORD& > client_id=CLIENT_ID > client_secret=client_secret
- B 网站验证身份通过后,直接给出令牌。注意,这时不需要跳转,而是把令牌放在 JSON 数据里面,作为 HTTP 回应,A 因此拿到令牌。
客户端模式
客户端模式(Client Credentials Grant)指客户端以自己的名义,而不是以用户的名义,向"服务提供商"进行授权。
适用于没有前端的命令行应用,即在命令行下请求令牌。一般用来提供给我们完全信任的服务器端服务。
它的步骤如下:
(A)客户端向授权服务器进行身份认证,并要求一个访问令牌。
(B)授权服务器确认无误后,向客户端提供访问令牌。
- A 应用在命令行向 B 发出请求。
> https://oauth.b.com/token? > grant_type=client_credentials& > client_id=CLIENT_ID& > client_secret=CLIENT_SECRET
- B 网站验证通过以后,直接返回令牌。
2.2 令牌的使用
A 网站拿到令牌以后,就可以向 B 网站的 API 请求数据了。
此时,每个发到 API 的请求,都必须带有令牌。具体做法是在请求的头信息,加上一个Authorization字段,令牌就放在这个字段里面。
> curl -H "Authorization: Bearer ACCESS_TOKEN" \ > "https://api.b.com"
也可以通过添加请求参数access_token请求数据。
2.3 更新令牌
令牌的有效期到了,如果让用户重新走一遍上面的流程,再申请一个新的令牌,很可能体验不好,而且也没有必要。OAuth 2.0 允许用户自动更新令牌。
具体方法是,B 网站颁发令牌的时候,一次性颁发两个令牌,一个用于获取数据,另一个用于获取新的令牌(refresh token 字段)。令牌到期前,用户使用 refresh token 发一个请求,去更新令牌。
> https://b.com/oauth/token? > grant_type=refresh_token& # grant_type参数为refresh_token表示要求更新令牌 > client_id=CLIENT_ID& > client_secret=CLIENT_SECRET& > refresh_token=REFRESH_TOKEN # 用于更新令牌的令牌 >
三、Spring Security OAuth2快速开始
Spring Security是一个能够为基于Spring的企业应用系统提供声明式的安全访问控制解决方案的安全框架。Spring Security 主要实现了Authentication(认证,解决who are you? ) 和 Access Control(访问控制,也就是what are you allowed to do?,也称为Authorization)。Spring Security在架构上将认证与授权分离,并提供了扩展点。
认证(Authentication) :用户认证就是判断一个用户的身份是否合法的过程,用户去访问系统资源时系统要求验证用户的身份信息,身份合法方可继续访问,不合法则拒绝访问。常见的用户身份认证方式有:用户名密码登录,二维码登录,手机短信登录,指纹认证等方式。
授权(Authorization): 授权是用户认证通过根据用户的权限来控制用户访问资源的过程,拥有资源的访问权限则正常访问,没有权限则拒绝访问。
将OAuth2和Spring Security集成,就可以得到一套完整的安全解决方案。我们可以通过Spring Security OAuth2构建一个授权服务器来验证用户身份以提供access_token,并使用这个access_token来从资源服务器请求数据。
3.1 授权服务器
- Authorization Endpoint :授权端点,进行授权
- Token Endpoint :令牌端点,经过授权拿到对应的Token
- Introspection Endpoint :校验端点,校验Token的合法性
- Revocation Endpoint :撤销端点,撤销授权
3.2 整体架构
流程:
- 用户访问,此时没有Token。Oauth2RestTemplate会报错,这个报错信息会被Oauth2ClientContextFilter捕获并重定向到授权服务器。
- 授权服务器通过Authorization Endpoint(/oauth/authorize)进行授权,并通过AuthorizationServerTokenServices生成授权码并返回给客户端。
- 客户端拿到授权码去授权服务器通过Token Endpoint(/oauth/token)调用AuthorizationServerTokenServices生成Token并返回给客户端
- 客户端拿到Token去资源服务器访问资源,一般会通过Oauth2AuthenticationManager调用ResourceServerTokenServices进行校验。校验通过可以获取资源。
3.3 授权码模式
引入依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.security.oauth</groupId> <artifactId>spring-security-oauth2</artifactId> <version>2.3.4.RELEASE</version> </dependency>
或者 引入spring cloud oauth2依赖
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-oauth2</artifactId> </dependency> <!-- spring cloud --> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>Hoxton.SR8</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>
配置 spring security
@Configuration public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Override protected void configure(HttpSecurity http) throws Exception { http.formLogin().permitAll() .and().authorizeRequests() .antMatchers("/oauth/**").permitAll() .anyRequest().authenticated() .and().logout().permitAll() .and().csrf().disable(); } } @Service public class UserService implements UserDetailsService { @Autowired private PasswordEncoder passwordEncoder; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { String password = passwordEncoder.encode("123456"); return new User("fox",password, AuthorityUtils.commaSeparatedStringToAuthorityList("admin")); } } @RestController @RequestMapping("/user") public class UserController { @RequestMapping("/getCurrentUser") public Object getCurrentUser(Authentication authentication) { return authentication.getPrincipal(); } }
配置授权服务器
@Configuration @EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Autowired private PasswordEncoder passwordEncoder; @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() //配置client_id .withClient("client") //配置client-secret .secret(passwordEncoder.encode("123123")) //配置访问token的有效期 .accessTokenValiditySeconds(3600) //配置刷新token的有效期 .refreshTokenValiditySeconds(864000) //配置redirect_uri,用于授权成功后跳转 .redirectUris("http://www.baidu.com") //配置申请的权限范围 .scopes("all") //配置grant_type,表示授权类型 .authorizedGrantTypes("authorization_code"); } }
配置资源服务器
@Configuration @EnableResourceServer public class ResourceServiceConfig extends ResourceServerConfigurerAdapter { @Override public void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest().authenticated() .and().requestMatchers().antMatchers("/user/**"); } }
测试
获取授权码
http://localhost:8080/oauth/authorize?response_type=code&client_id=client
登录之后进入
选择Approve,点击授权获取授权码
获取令牌
根据授权码通过post请求获取
访问资源
或者
3.4 简化模式
authorizedGrantType添加implicit
测试
登录之后进入授权页面,确定授权后浏览器会重定向到指定路径,并以Hash的形式存放在重定向uri的fargment中:
3.5 密码模式
修改WebSecurityConfig,增加AuthenticationManager
@Configuration public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Override protected void configure(HttpSecurity http) throws Exception { http.formLogin().permitAll() .and().authorizeRequests() .antMatchers("/oauth/**").permitAll() .anyRequest().authenticated() .and().logout().permitAll() .and().csrf().disable(); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } }
修改AuthorizationServerConfig配置
@Configuration @EnableAuthorizationServer public class AuthorizationServerConfig2 extends AuthorizationServerConfigurerAdapter { @Autowired private PasswordEncoder passwordEncoder; @Autowired private AuthenticationManager authenticationManagerBean; @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints.authenticationManager(authenticationManagerBean) //使用密码模式需要配置 .allowedTokenEndpointRequestMethods(HttpMethod.GET,HttpMethod.POST); //支持GET,POST请求 } @Override public void configure(AuthorizationServerSecurityConfigurer security) throws Exception { //允许表单认证 security.allowFormAuthenticationForClients(); } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { /** *授权码模式 *http://localhost:8080/oauth/authorize?response_type=code&client_id=client&redirect_uri=http://www.baidu.com&scope=all *http://localhost:8080/oauth/authorize?response_type=code&client_id=client * * password模式 * http://localhost:8080/oauth/token?username=fox&password=123456&grant_type=password&client_id=client&client_secret=123123&scope=all * * 客户端模式 * http://localhost:8080/oauth/token?grant_type=client_credentials&scope=all&client_id=client&client_secret=123123 */ clients.inMemory() //配置client_id .withClient("client") //配置client-secret .secret(passwordEncoder.encode("123123")) //配置访问token的有效期 .accessTokenValiditySeconds(3600) //配置刷新token的有效期 .refreshTokenValiditySeconds(864000) //配置redirect_uri,用于授权成功后跳转 .redirectUris("http://www.baidu.com") //配置申请的权限范围 .scopes("all") /** * 配置grant_type,表示授权类型 * authorization_code: 授权码 * password: 密码 * client_credentials: 客户端 */ .authorizedGrantTypes("authorization_code","password","client_credentials"); } }
获取令牌
通过浏览器测试,需要配置支持get请求和表单验证
通过Postman测试
访问资源
3.6 客户端模式
获取令牌
3.7 更新令牌
使用oauth2时,如果令牌失效了,可以使用刷新令牌通过refresh_token的授权模式再次获取access_token。只需修改认证服务器的配置,添加refresh_token的授权模式即可。
修改授权服务器配置,增加refresh_token配置
@Autowired private UserService userService; @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints.authenticationManager(authenticationManagerBean) //使用密码模式需要配置 // .tokenStore(tokenStore) //指定token存储到redis .reuseRefreshTokens(false) //refresh_token是否重复使用 .userDetailsService(userService) //刷新令牌授权包含对用户信息的检查 .allowedTokenEndpointRequestMethods(HttpMethod.GET,HttpMethod.POST); //支持GET,POST请求 } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { /** *授权码模式 *http://localhost:8080/oauth/authorize?response_type=code&client_id=client&redirect_uri=http://www.baidu.com&scope=all *http://localhost:8080/oauth/authorize?response_type=code&client_id=client * * password模式 * http://localhost:8080/oauth/token?username=fox&password=123456&grant_type=password&client_id=client&client_secret=123123&scope=all * * 客户端模式 * http://localhost:8080/oauth/token?grant_type=client_credentials&scope=all&client_id=client&client_secret=123123 */ clients.inMemory() //配置client_id .withClient("client") //配置client-secret .secret(passwordEncoder.encode("123123")) //配置访问token的有效期 .accessTokenValiditySeconds(3600) //配置刷新token的有效期 .refreshTokenValiditySeconds(864000) //配置redirect_uri,用于授权成功后跳转 .redirectUris("http://www.baidu.com") //配置申请的权限范围 .scopes("all") /** * 配置grant_type,表示授权类型 * authorization_code: 授权码 * password: 密码 * client_credentials: 客户端 * refresh_token: 更新令牌 */ .authorizedGrantTypes("authorization_code","password","client_credentials","refresh_token"); }
通过密码模式测试
3.8 基于redis存储Token
引入依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>
修改application.yaml
spring: redis: host: 127.0.0.1 database: 0
编写redis配置类
@Configuration public class RedisConfig { @Autowired private RedisConnectionFactory redisConnectionFactory; @Bean public TokenStore tokenStore(){ return new RedisTokenStore(redisConnectionFactory); } }
在授权服务器配置中指定令牌的存储策略为Redis
@Autowired private TokenStore tokenStore; @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints.authenticationManager(authenticationManagerBean) //使用密码模式需要配置 .tokenStore(tokenStore) //指定token存储到redis .reuseRefreshTokens(false) //refresh_token是否重复使用 .userDetailsService(userService) //刷新令牌授权包含对用户信息的检查 .allowedTokenEndpointRequestMethods(HttpMethod.GET,HttpMethod.POST); //支持GET,POST请求 }
四、Oauth2实现单点登录
4.1 什么是单点登录
单点登录(Single Sign On),简称为 SSO,是比较流行的企业业务整合的解决方案之一。它的用途在于,不管多么复杂的应用群,只要在用户权限范围内,那么就可以做到,用户只需要登录一次就可以访问权限范围内的所有应用子系统。
4.2 单点登录常见实现方式
同域单点登录
适用场景:都是企业自己的系统,所有系统都使用同一个一级域名通过不同的二级域名来区分
核心原理:
- 门户系统设置 Cookie 的 domain 为一级域名也就是 zlt.com,这样就可以共享门户的 Cookie 给所有的使用该域名(xxx.zlt.com)的系统
- 使用 Spring Session 等技术让所有系统共享 Session
- 这样只要门户系统登录之后无论跳转应用1或者应用2,都能通过门户 Cookie 中的 sessionId 读取到 Session 中的登录信息实现单点登录
跨域单点登录
单点登录之间的系统域名不一样,例如第三方系统。由于域名不一样不能共享 Cookie 了,这样就需要通过一个单独的授权服务(UAA)来做统一登录,并基于共享UAA的 Cookie 来实现单点登录。
核心原理:
- 访问系统1判断未登录,则跳转到UAA系统请求授权
- 在UAA系统域名 sso.com 下的登录地址中输入用户名/密码完成登录
- 登录成功后UAA系统把登录信息保存到 Session 中,并在浏览器写入域为 sso.com 的 Cookie
- 访问系统2判断未登录,则跳转到UAA系统请求授权
- 由于是跳转到UAA系统的域名 sso.com 下,所以能通过浏览器中UAA的 Cookie 读取到 Session 中之前的登录信息完成单点登录
基于Oauth2跨域单点登录
4.3 Spring Secuirty Oauth2单点登录实战
Oauth2单点登录除了需要授权中心完成统一登录/授权逻辑之外,各个系统本身(sso客户端)也需要实现以下逻辑:
- 拦截请求判断登录状态
- 与 UAA授权中心 通过 Oauth2授权码模式 交互完成登录/单点登录
- 保存用户登录信息
以上逻辑只需使用一个 @EnableOAuth2Sso 注解即可实现
创建客户端
1)引入依赖
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-oauth2</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>
2)修改application.properties
server: port: 8081 #防止Cookie冲突,冲突会导致登录验证不通过 servlet: session: cookie: name: OAUTH2-CLIENT-SESSIONID${server.port} #与授权服务器对应的配置 security: oauth2: client: client-id: client client-secret: 123123 user-authorization-uri: http://localhost:8080/oauth/authorize access-token-uri: http://localhost:8080/oauth/token resource: token-info-uri: http://localhost:8080/oauth/check_token
3)在启动类上添加@EnableOAuth2Sso注解来启用单点登录功能
@EnableOAuth2Sso单点登录的原理简单来说就是:标注有@EnableOAuth2Sso的OAuth2 Client应用在通过某种OAuth2授权流程获取访问令牌后(一般是授权码流程),通过访问令牌访问userDetails用户明细这个受保护资源服务,获取用户信息后,将用户信息转换为Spring Security上下文中的认证后凭证Authentication,从而完成标注有@EnableOAuth2Sso的OAuth2 Client应用自身的登录认证的过程。整个过程是基于OAuth2的SSO单点登录
@SpringBootApplication @EnableOAuth2Sso public class Oauth2SsoClientDemoApplication { public static void main(String[] args) { SpringApplication.run(Oauth2SsoClientDemoApplication.class, args); } }
4)添加接口用于获取当前登录用户信息
@RestController @RequestMapping("/user") public class UserController { @RequestMapping("/getCurrentUser") public Object getCurrentUser(Authentication authentication) { return authentication; } }
创建授权服务器
1)修改授权服务器中的AuthorizationServerConfig类
@Configuration @EnableAuthorizationServer public class AuthorizationServerConfig3 extends AuthorizationServerConfigurerAdapter { @Autowired private PasswordEncoder passwordEncoder; @Override public void configure(AuthorizationServerSecurityConfigurer security) throws Exception { //允许表单认证 security.allowFormAuthenticationForClients(); //校验token security.checkTokenAccess("permitAll()"); } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { /** *授权码模式 */ clients.inMemory() //配置client_id .withClient("client") //配置client-secret .secret(passwordEncoder.encode("123123")) //配置访问token的有效期 .accessTokenValiditySeconds(3600) //配置刷新token的有效期 .refreshTokenValiditySeconds(864000) //配置redirect_uri,用于授权成功后跳转 .redirectUris("http://localhost:8081/login", "http://localhost:8082/login") //自动授权配置 .autoApprove(true) //配置申请的权限范围 .scopes("all") /** * 配置grant_type,表示授权类型 * authorization_code: 授权码模式 */ .authorizedGrantTypes("authorization_code"); } }
2)启动授权服务和客户端服务
测试: 访问客户端需要授权的接口http://localhost:8081/user/getCurrentUser 会跳转到授权服务的登录界面
授权后会跳转到原来需要权限的接口地址,展示登录用户信息
3)模拟两个客户端8081,8082
修改application.properties配置
server.port=8082 #防止Cookie冲突,冲突会导致登录验证不通过 server.servlet.session.cookie.name=OAUTH2-CLIENT-SESSIONID${server.port}
修改授权服务器配置,配置多个跳转路径
//配置redirect_uri,用于授权成功后跳转 .redirectUris("http://localhost:8081/login", "http://localhost:8082/login")
8081登录成功之后,8082无需再次登录就可以访问
http://localhost:8082/user/getCurrentUser
4.4 Oauth2整合网关实现微服务单点登录
网关整合 OAuth2.0 有两种思路,一种是授权服务器生成令牌, 所有请求统一在网关层验证,判断权限等操作;另一种是由各资源服务处理,网关只做请求转发。 比较常用的是第一种,把API网关作为OAuth2.0的资源服务器角色,实现接入客户端权限拦截、令牌解析并转发当前登录用户信息给微服务,这样下游微服务就不需要关心令牌格式解析以及OAuth2.0相关机制了。
网关在认证授权体系里主要负责两件事:
-
作为OAuth2.0的资源服务器角色,实现接入方权限拦截。
-
令牌解析并转发当前登录用户信息(明文token)给微服务
微服务拿到明文token(明文token中包含登录用户的身份和权限信息)后也需要做两件事:
-
用户授权拦截(看当前用户是否有权访问该资源)
-
将用户信息存储进当前线程上下文(有利于后续业务逻辑随时获取当前用户信息)
核心代码,网关自定义全局过滤器进行身份认证
@Component @Order(0) public class AuthenticationFilter implements GlobalFilter, InitializingBean { @Autowired private RestTemplate restTemplate; private static Set<String> shouldSkipUrl = new LinkedHashSet<>(); @Override public void afterPropertiesSet() throws Exception { // 不拦截认证的请求 shouldSkipUrl.add("/oauth/token"); shouldSkipUrl.add("/oauth/check_token"); shouldSkipUrl.add("/user/getCurrentUser"); } @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String requestPath = exchange.getRequest().getURI().getPath(); //不需要认证的url if(shouldSkip(requestPath)) { return chain.filter(exchange); } //获取请求头 String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization"); //请求头为空 if(StringUtils.isEmpty(authHeader)) { throw new RuntimeException("请求头为空"); } TokenInfo tokenInfo=null; try { //获取token信息 tokenInfo = getTokenInfo(authHeader); }catch (Exception e) { throw new RuntimeException("校验令牌异常"); } exchange.getAttributes().put("tokenInfo",tokenInfo); return chain.filter(exchange); } private boolean shouldSkip(String reqPath) { for(String skipPath:shouldSkipUrl) { if(reqPath.contains(skipPath)) { return true; } } return false; } private TokenInfo getTokenInfo(String authHeader) { // 获取token的值 String token = StringUtils.substringAfter(authHeader, "bearer "); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); headers.setBasicAuth(MDA.clientId, MDA.clientSecret); MultiValueMap<String, String> params = new LinkedMultiValueMap<>(); params.add("token", token); HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(params, headers); ResponseEntity<TokenInfo> response = restTemplate.exchange(MDA.checkTokenUrl, HttpMethod.POST, entity, TokenInfo.class); return response.getBody(); } }
五、JWT
OAuth 2.0是当前业界标准的授权协议,它的核心是若干个针对不同场景的令牌颁发和管理流程;而JWT是一种轻量级、自包含的令牌,可用于在微服务间安全地传递用户信息。
5.1 什么是JWT
JSON Web Token(JWT)是一个开放的行业标准(RFC 7519),它定义了一种简介的、自包含的协议格式,用于在通信双方传递json对象,传递的信息经过数字签名可以被验证和信任。JWT可以使用HMAC算法或使用RSA的公钥/私钥对来签名,防止被篡改。
官网: https://jwt.io/
标准: https://tools.ietf.org/html/rfc7519
JWT令牌的优点:
- jwt基于json,非常方便解析。
- 可以在令牌中自定义丰富的内容,易扩展。
- 通过非对称加密算法及数字签名技术,JWT防止篡改,安全性高。
- 资源服务使用JWT可不依赖授权服务即可完成授权。
缺点:
JWT令牌较长,占存储空间比较大。
5.2 JWT组成
一个JWT实际上就是一个字符串,它由三部分组成,头部(header)、载荷(payload)与签名(signature)。
头部(header)
头部用于描述关于该JWT的最基本的信息:类型(即JWT)以及签名所用的算法(如HMACSHA256或RSA)等。
这也可以被表示成一个JSON对象:
{ "alg": "HS256", "typ": "JWT" }
然后将头部进行base64加密(该加密是可以对称解密的),构成了第一部分:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
载荷(payload)
第二部分是载荷,就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分:
- 标准中注册的声明(建议但不强制使用)
iss: jwt签发者
sub: jwt所面向的用户
aud: 接收jwt的一方
exp: jwt的过期时间,这个过期时间必须要大于签发时间
nbf: 定义在什么时间之前,该jwt都是不可用的.
iat: jwt的签发时间
jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
- 公共的声明 公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密.
- 私有的声明 私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。
定义一个payload:
{ "sub": "1234567890", "name": "John Doe", "iat": 1516239022 }
然后将其进行base64加密,得到Jwt的第二部分:
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
签名(signature)
jwt的第三部分是一个签证信息,这个签证信息由三部分组成:
- header (base64后的)
- payload (base64后的)
- secret(盐,一定要保密)
这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分:
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload); var signature = HMACSHA256(encodedString, 'fox'); // khA7TNYc7_0iELcDyTc7gHBZ_xfIcgbfpzUNWwQtzME
将这三部分用.连接成一个完整的字符串,构成了最终的jwt:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.khA7TNYc7_0iELcDyTc7gHBZ_xfIcgbfpzUNWwQtzME
注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。
如何应用
一般是在请求头里加入Authorization,并加上Bearer标注:
fetch('api/user/1', { headers: { 'Authorization': 'Bearer ' + token } })
服务端会验证token,如果验证通过就会返回相应的资源。整个流程就是这样的:
5.3 JJWT
JJWT是一个提供端到端的JWT创建和验证的Java库,永远免费和开源(Apache License,版本2.0)。JJW很容易使用和理解。它被设计成一个以建筑为中心的流畅界面,隐藏了它的大部分复杂性。
快速开始
引入依赖
<!--JWT依赖--> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>
创建token
创建测试类,生成token
@Test public void test() { //创建一个JwtBuilder对象 JwtBuilder jwtBuilder = Jwts.builder() //声明的标识{"jti":"666"} .setId("666") //主体,用户{"sub":"Fox"} .setSubject("Fox") //创建日期{"ita":"xxxxxx"} .setIssuedAt(new Date()) //签名手段,参数1:算法,参数2:盐 .signWith(SignatureAlgorithm.HS256, "123123"); //获取token String token = jwtBuilder.compact(); System.out.println(token); //三部分的base64解密 System.out.println("========="); String[] split = token.split("\\."); System.out.println(Base64Codec.BASE64.decodeToString(split[0])); System.out.println(Base64Codec.BASE64.decodeToString(split[1])); //无法解密 System.out.println(Base64Codec.BASE64.decodeToString(split[2])); }
运行结果
token的验证解析
在web应用中由服务端创建了token然后发给客户端,客户端在下次向服务端发送请求时需要携带这个token(这就好像是拿着一张门票一样),那服务端接到这个token应该解析出token中的信息(例如用户id),根据这些信息查询数据库返回相应的结果。
@Test public void testParseToken(){ //token String token ="eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI2NjYiLCJzdWIiOiJGb3giLCJpYXQiOjE2MDgyNzI1NDh9" + ".Hz7tk6pJaest_jxFrJ4BWiMg3HQxjwY9cGmJ4GQwfuU"; //解析token获取载荷中的声明对象 Claims claims = Jwts.parser() .setSigningKey("123123") .parseClaimsJws(token) .getBody(); System.out.println("id:"+claims.getId()); System.out.println("subject:"+claims.getSubject()); System.out.println("issuedAt:"+claims.getIssuedAt()); }
试着将token或签名秘钥篡改一下,会发现运行时就会报错,所以解析token也就是验证token
token过期校验
有很多时候,我们并不希望签发的token是永久生效的,所以我们可以为token添加一个过期时间。原因:从服务器发出的token,服务器自己并不做记录,就存在一个弊端:服务端无法主动控制某个token的立刻失效。
当未过期时可以正常读取,当过期时会引发io.jsonwebtoken.ExpiredJwtException异常。
自定义存储信息
我们刚才的例子只是存储了id和subject两个信息,如果你想存储更多的信息(例如角色)可以自定义claims。
@Test public void test() { //创建一个JwtBuilder对象 JwtBuilder jwtBuilder = Jwts.builder() //声明的标识{"jti":"666"} .setId("666") //主体,用户{"sub":"Fox"} .setSubject("Fox") //创建日期{"ita":"xxxxxx"} .setIssuedAt(new Date()) //设置过期时间 1分钟 .setExpiration(new Date(System.currentTimeMillis()+60*1000)) //直接传入map // .addClaims(map) .claim("roles","admin") .claim("logo","xxx.jpg") //签名手段,参数1:算法,参数2:盐 .signWith(SignatureAlgorithm.HS256, "123123"); //获取token String token = jwtBuilder.compact(); System.out.println(token); //三部分的base64解密 System.out.println("========="); String[] split = token.split("\\."); System.out.println(Base64Codec.BASE64.decodeToString(split[0])); System.out.println(Base64Codec.BASE64.decodeToString(split[1])); //无法解密 System.out.println(Base64Codec.BASE64.decodeToString(split[2])); } @Test public void testParseToken(){ //token String token ="eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI2NjYiLCJzdWIiOiJGb3giLCJpYXQiOjE2MDgyNzYzMTUsImV4cCI6MTYwODI3NjM3NSwicm9sZXMiOiJhZG1pbiIsImxvZ28iOiJ4eHguanBnIn0.Geg2tmkmJ9iWCWdvZNE3jRSfRaXaR4P3kiPDG3Lb0z4"; //解析token获取载荷中的声明对象 Claims claims = Jwts.parser() .setSigningKey("123123") .parseClaimsJws(token) .getBody(); System.out.println("id:"+claims.getId()); System.out.println("subject:"+claims.getSubject()); System.out.println("issuedAt:"+claims.getIssuedAt()); DateFormat sf =new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); System.out.println("签发时间:"+sf.format(claims.getIssuedAt())); System.out.println("过期时间:"+sf.format(claims.getExpiration())); System.out.println("当前时间:"+sf.format(new Date())); System.out.println("roles:"+claims.get("roles")); System.out.println("logo:"+claims.get("logo")); }
结果
5.4 Spring Security Oauth2整合JWT
整合JWT
在之前的spring security Oauth2的代码基础上修改
引入依赖
<dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-jwt</artifactId> <version>1.0.9.RELEASE</version> </dependency>
添加配置文件JwtTokenStoreConfig.java
@Configuration public class JwtTokenStoreConfig { @Bean public TokenStore jwtTokenStore(){ return new JwtTokenStore(jwtAccessTokenConverter()); } @Bean public JwtAccessTokenConverter jwtAccessTokenConverter(){ JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter(); //配置JWT使用的秘钥 accessTokenConverter.setSigningKey("123123"); return accessTokenConverter; } }
在授权服务器配置中指定令牌的存储策略为JWT
用密码模式测试
发现获取到的令牌已经变成了JWT令牌,将access_token拿到https://jwt.io/ 网站上去解析下可以获得其中内容。
扩展JWT中的存储内容
有时候我们需要扩展JWT中存储的内容,这里我们在JWT中扩展一个 key为enhance,value为enhance info 的数据。 继承TokenEnhancer实现一个JWT内容增强器
public class JwtTokenEnhancer implements TokenEnhancer { @Override public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { Map<String, Object> info = new HashMap<>(); info.put("enhance", "enhance info"); ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(info); return accessToken; } }
创建一个JwtTokenEnhancer实例
@Bean public JwtTokenEnhancer jwtTokenEnhancer() { return new JwtTokenEnhancer(); }
在授权服务器配置中配置JWT的内容增强器
@Autowired private JwtTokenEnhancer jwtTokenEnhancer; @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { //配置JWT的内容增强器 TokenEnhancerChain enhancerChain = new TokenEnhancerChain(); List<TokenEnhancer> delegates = new ArrayList<>(); delegates.add(jwtTokenEnhancer); delegates.add(jwtAccessTokenConverter); enhancerChain.setTokenEnhancers(delegates); endpoints.authenticationManager(authenticationManagerBean) //使用密码模式需要配置 .tokenStore(tokenStore) //配置存储令牌策略 .accessTokenConverter(jwtAccessTokenConverter) .tokenEnhancer(enhancerChain) //配置tokenEnhancer .reuseRefreshTokens(false) //refresh_token是否重复使用 .userDetailsService(userService) //刷新令牌授权包含对用户信息的检查 .allowedTokenEndpointRequestMethods(HttpMethod.GET,HttpMethod.POST); //支持GET,POST请求 }
运行项目后使用密码模式来获取令牌,之后对令牌进行解析,发现已经包含扩展的内容。
解析JWT
添加依赖
<!--JWT依赖--> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>
修改UserController类,使用jjwt工具类来解析Authorization头中存储的JWT内容
@GetMapping("/getCurrentUser") public Object getCurrentUser(Authentication authentication, HttpServletRequest request) { String header = request.getHeader("Authorization"); String token = null; if(header!=null){ token = header.substring(header.indexOf("bearer") + 7); }else { token = request.getParameter("access_token"); } return Jwts.parser() .setSigningKey("123123".getBytes(StandardCharsets.UTF_8)) .parseClaimsJws(token) .getBody(); }
将令牌放入Authorization头中,访问如下地址获取信息: http://localhost:8080/user/getCurrentUser
刷新令牌
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· DeepSeek “源神”启动!「GitHub 热点速览」
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
· DeepSeek R1 简明指南:架构、训练、本地部署及硬件要求
· NetPad:一个.NET开源、跨平台的C#编辑器