这里面的笔记都是我这里抄抄那里抄抄得来的,然后也经过了一些简单的测试,主要是用来拓宽知识面用的,毕竟到了项目开发中,不会裸用这些技术框架,通常都是采用第三方集成框架。
另外需要说明的是,这里要介绍的Spring Security OAuth2框架应该有点过时了,在Spring官网已经没有了项目介绍,现在项目似乎已经升级,并且迁移为Spring Authorization Server项目。所以,大家看自己需要是否要继续学习Spring Authorization Server项目了。
系列上一篇文章:《【分布式微服务专题】SpringSecurity快速入门》
权威文档地址:OAuth2.0协议介绍
OAuth(Open Authorization,开放授权)是一个关于授权(authorization)的开放网络标准协议,允许用户授权第三方应用访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方移动应用或分享他们数据的所有内容。OAuth在全世界得到广泛应用,目前的版本是2.0版。
注意:OAuth2只是一种标准协议,或者叫作:规范! 其实在我们计算机学习中,你会发现很多各种各样的规范,这些规范往往指的是一种约定,并不特指某一具体的项目,SpringSecurityOAuth2才是一个具体的项目。可别被带偏了呀
标准协议特点:
案例1:云快印
有一个【云快印】的网站,可以将用户存储在的百度网盘上的照片打印出来。用户为了使用该服务,必须让【云快印】读取自己储存在百度网盘上的照片。只有得到用户的授权,百度网盘才会同意【云快印】取这些照片。那么,【云快印】怎样获得用户的授权呢?
传统方法是,用户将自己百度网盘的用户名和密码,告诉【云快印】,后者就可以读取用户的照片了。这样的做法有以下几个严重的缺点:
综上缺陷,这显然不是我们期待的方案。
案例2:CSDN登录
附上一张常见的OAuth2场景:CSDN接入微信QQ开放平台,用户可以通过微信QQ登录CSDN
角色:
概念:
优点:
缺点:
OAuth在【客户端】与【服务提供商】之间,设置了一个授权层(authorization layer)。【客户端】不能直接登录【服务提供商】,只能登录授权层,以此将用户与客户端区分开来。【客户端】登录授权层所用的令牌(token),与用户的密码不同。用户可以在登录的时候,指定授权层令牌的权限范围和有效期,【客户端】登录授权层以后,【服务提供商】根据令牌的权限范围和有效期,向【客户端】开放用户储存的资料。
(1)用户打开客户端以后,客户端要求用户给予授权
(2)用户同意给予客户端授权
(3)客户端使用上一步获得的授权,向授权服务器申请令牌。
(4)授权服务器对客户端进行认证以后,确认无误,同意发放令牌
(5)客户端使用令牌,向资源服务器申请获取资源
(6)资源服务器确认令牌无误,同意向客户端开放资源
令牌(token)与密码(password)的作用是一样的,都可以进入系统,但是有三点差异
(1)令牌是短期的,到期会自动失效,用户自己无法修改。密码一般长期有效,用户不修改,就不会发生变化
(2)令牌可以被数据所有者撤销,会立即失效。密码一般不允许被他人撤销
(3)令牌有权限范围(scope)。对于网络服务来说,只读令牌就比读写令牌更安全。密码一般是完整权限
上面这些设计,保证了令牌既可以让第三方应用获得权限,同时又随时可控,不会危及系统安全。这就是 OAuth 2.0 的优点
客户端必须得到用户的授权(authorization grant),才能获得令牌(access token)。OAuth 2.0 对于如何颁发令牌的细节,规定得非常详细。具体来说,一共分成四种授权类型(authorization grant),即四种颁发令牌的方式,适用于不同的互联网场景。它们分别是:
不过不管哪一种授权方式,它们的本质流程都是:三方客户端到服务提供商系统中备案,说明自己身份,然后拿到身份识别码【客户端id + 客户端密钥】。这是为了防止令牌被滥用,没有备案过的第三方应用,是不会拿到令牌的。
在客户端授权中,我们通常需要用到以下参数:
目前主流的三方授权模式
授权码(authorization code)方式,指的是第三方应用先申请一个授权码,然后再用该码获取令牌。
特点:
一般性流程描述:
(1)用户访问客户端,后者将前者导向授权服务器
(2)用户选择是否给予客户端授权
(3)假设用户给予授权,授权服务器将用户导向客户端事先指定的【重定向URI(redirection URI)】,同时附上一个授权码
(4)客户端收到授权码,附上早先的【重定向URI】,向授权服务器申请令牌。这一步是在客户端的后台的服务器上完成的,对用户不可见
(5)授权服务器核对了授权码和重定向URI,确认无误后,向客户端发送访问令牌(access token)和更新令牌(refresh token)
案例:
1) A网站提供一个链接,用户点击后就会跳转到 B 网站,授权用户数据给 A 网站使用。下面就是 A 网站跳转 B 网站的一个示意链接
https://b.com/oauth/authorize? response_type=code& # 表示授权类型,此处固定为code client_id=CLIENT_ID& # 表示客户端的id,通常为必填 redirect_uri=CALLBACK_URL& # 表示重定向的uri。接受或拒绝请求后的跳转地址 scope=read # 表示申请的权限范围(这里是只读) state=xxx # 示客户端的当前状态,可以指定任意值,授权服务器会原封不动地返回这个值
2)用户跳转后,B 网站会要求用户登录,然后询问是否同意给予 A 网站授权。用户表示同意,这时 B 网站就会跳回redirect_uri参数指定的网址。跳转时,会传回一个授权码,就像下面这样
https://a.com/callback?code=AUTHORIZATION_CODE #code参数就是授权码
3)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 # 令牌颁发后的回调网址
4)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)】
特点:
一般性流程描述:
(1)用户访问客户端,后者将前者导向授权服务器
(2)用户选择是否给予客户端授权
(3)假设用户给予授权,授权服务器将用户导向客户端事先指定的【重定向URI(redirection URI)】,并在URI的Hash部分包含了访问令牌
案例:
1)A 网站提供一个链接,要求用户跳转到 B 网站,授权用户数据给 A 网站使用
https://b.com/oauth/authorize? response_type=token& # response_type参数为token,表示要求直接返回令牌 client_id=CLIENT_ID& redirect_uri=CALLBACK_URL& scope=read
2)用户跳转到 B 网站,登录后同意给予 A 网站授权。这时,B 网站就会跳回redirect_uri参数指定的跳转网址,并且把令牌作为 URL 参数,传给 A 网站
https://a.com/callback#token=ACCESS_TOKEN #token参数就是令牌,A 网站直接在前端拿到令牌
如果你高度信任某个应用,RFC 6749 也允许用户把用户名和密码,直接告诉该应用。该应用就使用你的密码,申请令牌,这种方式称为【密码式(password)】。
在这种模式中,用户必须把自己的密码给客户端,但是客户端不得储存密码。这通常用在用户对客户端高度信任的情况下,比如客户端是操作系统的一部分,或者由一个著名公司出品。而授权服务器只有在其他授权模式无法执行的情况下,才能考虑使用这种模式。
特点:
一般性流程描述:
(1)用户向客户端提供用户名和密码
(2)客户端将用户名和密码发给授权服务器,向后者请求令牌
(3)授权服务器确认无误后,向客户端提供访问令牌
案例:
1)A 网站要求用户提供 B 网站的用户名和密码,拿到以后,A 就直接向 B 请求令牌。整个过程中,客户端不得保存用户的密码
https://oauth.b.com/token? grant_type=password& # 授权方式是"密码式" username=USERNAME& password=PASSWORD& client_id=CLIENT_ID
2)B 网站验证身份通过后,直接给出令牌。注意,这时不需要跳转,而是把令牌放在 JSON 数据里面,作为 HTTP 回应,A 因此拿到令牌
客户端模式(Client Credentials Grant)指客户端以自己的名义,而不是以用户的名义,向【服务提供商】请求授权。
适用于没有前端的命令行应用,即在命令行下请求令牌。一般用来提供给我们完全信任的服务器端服务。
特点:
一般性流程描述:
(1)客户端向授权服务器进行身份认证,并要求一个访问令牌
(2)授权服务器确认无误后,向客户端提供访问令牌
案例:
1)A 应用在命令行向 B 发出请求
https://oauth.b.com/token? grant_type=client_credentials& client_id=CLIENT_ID& client_secret=CLIENT_SECRET
2)B 网站验证通过以后,直接返回令牌
A 网站拿到令牌以后,就可以向 B 网站的 API 请求数据了。这个时候,每次发送的请求,都会带上令牌,具体做法是,在头信息header中,加上一个Authorization字段,然后把令牌值设置在该字段中
令牌到期之后,正常来说是需要有自动刷新的逻辑的,不然每次到期让用户重新申请授权,未免显得太麻烦,而且也没必要。于是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 # 用于更新令牌的令牌
首先,为了帮助更好的理解当前正在做什么,先再次声明以下内容:
我在前面大概介绍过SpringSecurity了,这边就不再赘述了。但首先我们得再次声明一点:SpringSecurity主要实现了【认证】和【访问控制】。
正常在我们的企业应用中,需要结合SpringSecurity与OAuth2,这样才算是得到一套完整的解决方案。我们可以通过Spring Security + OAuth2构建一个授权服务器来验证用户身份以提供access_token,并使用这个access_token来从资源服务器请求数据。
上面是一个典型授权服务器的节点图。它们分别有如下作用:
上图的流程:
1)引入依赖
org.springframework.boot spring-boot-starter-security org.springframework.security.oauth spring-security-oauth2 2.5.2.RELEASE
2)配置 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(); } }
3)配置授权服务器
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("123456")) // 配置访问token的有效期 .accessTokenValiditySeconds(3600) // 配置刷新token的有效期 .refreshTokenValiditySeconds(864000) // 配置redirect_uri,用于授权成功后跳转 .redirectUris("http://www.baidu.com") // 配置申请的权限范围 .scopes("all") // 配置grant_type,表示授权类型 .authorizedGrantTypes("authorization_code"); } }
4)配置资源服务器
@Configuration @EnableResourceServer public class ResourceServiceConfig extends ResourceServerConfigurerAdapter { @Override public void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest().authenticated() .and().requestMatchers().antMatchers("/user/**"); } }
5)测试,获取授权码
输入:http://localhost:8081/oauth/authorize?response_type=code&client_id=client&redirect_uri=http://www.baidu.com&scope=all
接着会进入这个页面:
登录之后选择Approve
接着就会在地址栏显示如下:
上面的code即为我们的【授权码】
6)根据授权码获取令牌
下面需要使用Apifox来构建一个post请求,去授权服务器中获取令牌了
7)请求令牌,得到如下数据:
使用oauth2时,如果令牌失效了,可以使用刷新令牌通过refresh_token的授权模式再次获取access_token。只需修改认证服务器的配置,添加refresh_token的授权模式即可。
1)修改授权服务器AuthorizationServerConfig配置,增加refresh_token配置
@Configuration @EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Autowired private PasswordEncoder passwordEncoder; @Autowired private ShenUserService userService; @Autowired private AuthenticationManager authenticationManagerBean; @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 { clients.inMemory() // 配置client_id .withClient("client") // 配置client-secret .secret(passwordEncoder.encode("123456")) // 配置访问token的有效期 .accessTokenValiditySeconds(3600) // 配置刷新token的有效期 .refreshTokenValiditySeconds(864000) // 配置redirect_uri,用于授权成功后跳转 .redirectUris("http://www.baidu.com") // 配置申请的权限范围 .scopes("all") // 配置grant_type,表示授权类型 .authorizedGrantTypes("authorization_code", "implicit", "refresh_token"); } }
测试获取token,得到如下结果:
相比之前的,多了一个refresh_token
我们生成的token可以存储在很多地方,比较常用的一种方式是存储到redis中。只需要3步即可
1)引入依赖
org.springframework.boot spring-boot-starter-data-redis org.apache.commons commons-pool2
2)修改application.yml
spring: redis: host: 127.0.0.1 database: 0
3)编写redis配置类,声明TokenStore Bean
@Configuration public class RedisConfig { @Autowired private RedisConnectionFactory redisConnectionFactory; @Bean public TokenStore tokenStore(){ return new RedisTokenStore(redisConnectionFactory); } }
4)在授权服务器AuthorizationServerConfig配置中指定令牌的存储策略为Redis
@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请求 }
我在前一篇笔记里面已经简单介绍过JWT了,感兴趣的朋友可以看看我上一篇文章,链接在最前面的【阅读导航】中有介绍。下面开始介绍整合步骤
1)引入依赖
org.springframework.security spring-security-jwt 1.0.9.RELEASE
2)添加配置文件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("123456"); return accessTokenConverter; } }
3)在授权服务器AuthorizationServerConfig配置中指定令牌的存储策略为JWT
@Configuration @EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Autowired private PasswordEncoder passwordEncoder; @Autowired private ShenUserService userService; @Autowired private AuthenticationManager authenticationManagerBean; @Autowired private TokenStore tokenStore; @Autowired private JwtAccessTokenConverter jwtAccessTokenConverter; @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints.authenticationManager(authenticationManagerBean) // 使用密码模式需要配置 .tokenStore(tokenStore) // 指定token存储到redis .accessTokenConverter(jwtAccessTokenConverter) .reuseRefreshTokens(false) // refresh_token是否重复使用 .userDetailsService(userService) // 刷新令牌授权包含对用户信息的检查 .allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST); // 支持GET,POST请求 } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() // 配置client_id .withClient("client") // 配置client-secret .secret(passwordEncoder.encode("123456")) // 配置访问token的有效期 .accessTokenValiditySeconds(3600) // 配置刷新token的有效期 .refreshTokenValiditySeconds(864000) // 配置redirect_uri,用于授权成功后跳转 .redirectUris("http://www.baidu.com") // 配置申请的权限范围 .scopes("all") // 配置grant_type,表示授权类型 .authorizedGrantTypes("authorization_code", "implicit", "refresh_token"); } }
4)接着还是按照上面的测试步骤走一遍,得到以下结果,令牌变成了JWT格式
有时候我们需要扩展JWT中存储的内容,这里我们在JWT中扩展一个 key为enhance,value为enhance info 的数据。
继承TokenEnhancer实现一个JWT内容增强器
1)新增JwtTokenEnhancer ,增强内容:("enhance", "enhance info")
@Component public class JwtTokenEnhancer implements TokenEnhancer { @Override public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { Mapinfo = new HashMap<>(); info.put("enhance", "enhance info"); ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(info); return accessToken; } }
2)在授权服务器AuthorizationServerConfig配置中配置JWT的内容增强器
@Configuration @EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Autowired private PasswordEncoder passwordEncoder; @Autowired private ShenUserService userService; @Autowired private AuthenticationManager authenticationManagerBean; @Autowired private TokenStore tokenStore; @Autowired private JwtAccessTokenConverter jwtAccessTokenConverter; @Autowired private JwtTokenEnhancer jwtTokenEnhancer; @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { //配置JWT的内容增强器 TokenEnhancerChain enhancerChain = new TokenEnhancerChain(); Listdelegates = new ArrayList<>(); delegates.add(jwtTokenEnhancer); delegates.add(jwtAccessTokenConverter); enhancerChain.setTokenEnhancers(delegates); endpoints.authenticationManager(authenticationManagerBean) // 使用密码模式需要配置 .tokenStore(tokenStore) // 指定token存储到redis .accessTokenConverter(jwtAccessTokenConverter) .tokenEnhancer(enhancerChain) //配置tokenEnhancer .reuseRefreshTokens(false) // refresh_token是否重复使用 .userDetailsService(userService) // 刷新令牌授权包含对用户信息的检查 .allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST); // 支持GET,POST请求 } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() // 配置client_id .withClient("client") // 配置client-secret .secret(passwordEncoder.encode("123456")) // 配置访问token的有效期 .accessTokenValiditySeconds(3600) // 配置刷新token的有效期 .refreshTokenValiditySeconds(864000) // 配置redirect_uri,用于授权成功后跳转 .redirectUris("http://www.baidu.com") // 配置申请的权限范围 .scopes("all") // 配置grant_type,表示授权类型 .authorizedGrantTypes("authorization_code", "implicit", "refresh_token"); } }
3)测试,获取令牌
4)对获取到的令牌,解密之后得到
{ "user_name": "Nico", "scope": [ "all" ], "exp": 1704786179, "authorities": [ "admin" ], "jti": "2DyqZildiHtLr7tojfDOwGqjnLE", "client_id": "client", "enhance": "enhance info" }
可以看见,在Payload中得到了被加强的内容
1)添加依赖
io.jsonwebtoken jjwt 0.9.1
2)修改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("123456".getBytes(StandardCharsets.UTF_8)) .parseClaimsJws(token) .getBody(); }
3)测试,不过这一次我们需要将得到的令牌放在header的Authorization中了,因为我们的逻辑是这么写的
接着访问上面的getCurrentUser接口,即会得到如下结果: