分布式权限校验
现在有一个登录问题,假如需要用户登录之后才能查询图书和借阅图书。那要怎么设计?
单体应用的权限校验原理:
- 浏览器向服务端发送请求,访问网站
- 服务端接收请求后,创建一个sessionid,存储在服务端,然后发送给浏览器作为Cookie保存
- 以后浏览器每次请求都携带这个cookie。这样服务端根据cookie中的sessionid判断是哪个用户。
但是分布式系统,各个微服务独立部署,用户登录了用户服务后,借阅服务和图书服务会知道用户登录了吗?
用户登录后,session中的用户数据保存在用户服务中,其他服务没有对应信息,按怎么能让其他服务获取这些信息呢,实现服务间的session同步呢?
将session移出服务器,统一存放!比如存到redis或者MySQL,这样就能保证所有服务获得session。
具体的实现步骤如下:
-
为每个服务引入依赖:
xml<dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> -
修改每个服务配置文件:
yamlspring: session: # 存储类型修改为redis store-type: redis redis: # redis 服务器信息 host: localhost
这样设置之后,每个服务只有在登录成功后才能访问!
只要在一个服务登录成功,其他服务就会直接调用,不用重复登录!
注意,如果是服务A 调用服务B,用户在服务B登录,然后访问服务A。那么访问是失败的!
因为服务A没有验证,请求没有携带有效cookie,验证失败,导致其无法访问服务B。
OAuth 2.0 实现单点登录
上面虽然解决了session共享问题,但是由于每个服务都有自己的验证模块,导致整个系统存在冗余功能,那么能否实现只有一个服务进行登录,然后可以访问其他的服务呢?
单点登录正好解决这一问题!
一些常见的第三方登录就是使用这种方式:比如淘宝和咸鱼可以使用支付宝账号登录,虽然他们属于三个不同的应用系统,需要获取支付宝用户信息并授权给其他应用。就要使用OAuth 2.0实现第三方授权。那么到底是怎么实现的呢?
OAuth 2.0一共有四种授权模式:
-
客户端模式:最简单的一种模式。
- 先向验证服务器请求获取一个token(令牌)
- 拿到令牌后去访问对应的资源(比如借阅信息,这样资源服务器才能知道访问者是谁以及是否登录成功)
这里的客户端可以是web、App、小程序或者第三方服务
这种模式比较简单,但是失去了用户验证的意义,压根就不是给用户校验准备的,更适合服务内部调用的场景!
- 密码模式:相比客户端模式,多了用户名和密码信息,用户需要提供用户名和密码才能获取到token
这种模式有一个缺点:会直接将账号密码信息泄露给客户端(或者第三方应用),这样风险很大,一般不会使用这种模式!
- 隐式授权模式:用户访问服务时,会重定向到认证服务器,认证服务器返回用户一个认证页面,等待用户授权,用户填写信息完成授权后,认证服务器返回token。用户再携带token去访问服务。
这种模式适合没有服务端的第三方应用页面,验证都是在验证服务器进行,不会泄露敏感信息,但是依然存在泄露token的风险!
- 授权码模式:最安全的一种模式,也是推荐使用的模式,手机上很多APP都是使用这种模式!
这种模式不会直接返回token,而是返回授权码,真正的token时通过应用服务器访问验证服务器获取的。
首先应用服务器和验证服务器会共享一个secret,验证服务器在用户完成验证后返回应用服务器一个授权码。
应用服务器将授权码和secret一起交给验证服务器,来生成token,并返回给应用服务器。token一直在服务器之间流转,不会直接给到客户端。
就算有人获取到授权码也没用,因为没有secret,就没法获取token。并且token不会返回给客户端,所以减少了泄露的风险!
搭建验证服务器
- 使用Spring官方提供的验证服务器,先在父项目中加入Spring Cloud依赖:
xml
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2021.0.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
- 新建一个auth-service子模块,并引入依赖:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<version>2.2.5.RELEASE</version>
</dependency>
- 添加配置文件:
yaml
server:
port: 8500
servlet:
#为了防止一会在服务之间跳转导致Cookie打架,(因为所有服务都是localhost,都会存JSESSIONID)
#这里修改以下 context-path,保证Cookie会使用指定路径,就不会和其他服务打架了
# 这样Cookie就会存放在localhost:8500/sso/**
# 注意:之后的请求都得加上这个路径
context-path: /sso
- 编写配置类(OAuth2的配置类和Spring Security的配置类):
java
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
auth.inMemoryAuthentication() // 直接创建一个用户
.passwordEncoder(encoder)
.withUser("test").password(encoder.encode("123456")).roles("USER");
}
@Override
@Bean //加入容器管理,在Oauth配置中使用
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManager();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated() // 通过认证了才可以去访问
.and()
.formLogin().permitAll(); // 放开表单登录权限
}
}
java
@Configuration
@EnableAuthorizationServer // 开启验证服务器
public class OAuth2Configuration extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthenticationManager authenticationManager;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
/*这个方式是对客户端进行配置,一个验证服务器可以预设很多个客户端
* 指定的客户端可以按照下面指定的方式进行验证
* @param clients 客户端配置工具
* */
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()// 直接硬编码创建,也可以从数据库取
.withClient("web") // 客户端名称
.secret(passwordEncoder.encode("654321")) // secret
.autoApprove(false) // 自动审批,暂时关闭
.scopes("book", "user","borrow") // 授权范围
// 5种授权模式
.authorizedGrantTypes("client_credentials","password","implicit","authorization_code", "refresh_token");
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.passwordEncoder(passwordEncoder) // 设置编码器
.allowFormAuthenticationForClients() // 允许客户端使用表单验证。一会我们的post请求中会携带表单信息
.checkTokenAccess("permitAll()"); // 允许所有的token查询请求
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager);
}
}
设置完后就可以启动服务了。然后可以用PostMan测试接口。
测试客户端模式
客户端模式只要提供id和secret就可以能拿到token,注意要加一个grant_type指明授权方式,默认请求路径是http://localhost:8500/sso/oauth/token
发送请求后,我们得到的token是以json格式返回给我们
还可以访问http://localhost:8500/sso/oauth/check_token来验证我们的token是否有效:
测试password模式
然后在请求头中添加Basic信息,之后发送请求就能获取到token
测试隐式授权模式
1.现在验证服务器上进行登录操作,而不是直接请求token。验证登录请求地址:http://localhost:8500/sso/oauth/authorize?client_id=web&response_type=token
注意response_type=token 必须!
输入用户名:test 密码:123456,然后登录
这时因为登录成功后,验证服务器要将结果返回给客户端,需要一个回调地址,这里需要在OAuth2的配置类里面配置
java
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()// 直接硬编码创建
.withClient("web") // 客户端名称
.secret(passwordEncoder.encode("654321")) // secret
.autoApprove(false) // 自动审批,暂时关闭
.scopes("book", "user","borrow") // 授权范围
// 可以写多个,当有多个时。需要在验证请求时指定使用哪个地址进行回调
.redirectUris("http://localhost:8201/login")
// 5种授权模式
.authorizedGrantTypes("client_credentials","password","implicit","authorization_code", "refresh_token");
}
设置好,重新启动服务,然后登录页面,
会自动请求重定向的地址,并且携带了token
测试授权码模式
流程和上面的一致,但是请求类型时code类型,请求地址是:http://localhost:8500/sso/oauth/authorize?client_id=web&response_type=code
这时能获取到授权码:amXZxw
然后使用postMan 进行获取token
刷新token
当token过期时,可以使用refresh_token 来获取新token
这里返回报错了,查看日志发现需要我们单独配置一个UserDetailService,我们在SpringSecurity的配置类中 加入如下代码:
java
@Override
@Bean
public UserDetailsService userDetailsServiceBean() throws Exception {
return super.userDetailsServiceBean();
}
然后在OAuth2的配置类中的endpoints设置方法中修改代码如下:
java
@Autowired
private UserDetailsService userDetailsService;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService);
}
然后重启服务器,再重新测试就能获取到新token
注意:refresh_token参数的值,是获取token时返回的refresh_token值,不是token值!
基于@EnableOAuth2Sso实现单点登录
前面已经搭建了验证服务器,SpringCloud为我们提供了客户端的直接实现,我们只需要加一个注解和少量配置就可以将服务作为一个单点登录应用,使用的是授权码模式
也就是说,这种模式只是将验证方式由默认登录形式改成统一在授权服务器登录!
- 先引入依赖
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<version>2.2.5.RELEASE</version>
</dependency>
- 在微服务的启动类上加@EnableOAuth2Sso注解
- 添加配置信息
yaml
security:
oauth2:
client:
client-id: web
client-secret: 654321
# 获取token地址
access-token-uri: http://localhost:8500/sso/oauth/token
# 验证页面地址
user-authorization-uri: http://localhost:8500/sso/oauth/authorize
resource:
# token信息获取和校验地址
token-info-uri: http://localhost:8500/sso/oauth/check_token
然后启动服务,就完成了单点登录验证!
这里把另外2种服务的重定向地址也加上,各自服务的依赖、配置、注解加上后启动。
这样只要在验证服务器登录,只要登陆过,就可以访问这三个服务了。
但是有个问题,由于session不同步,每次访问不同的服务都会重新去验证服务器验证一次。
这里有2个解决方案:
- 和之前一样做session统一存储
- 设置context-path路径,每个服务单独设置
但是这样依然没法解决服务间调用问题,所以仅依靠单点登录的模式不行。
基于@EnableResourceServer实现
上一种方式是将我们的服务当作单点应用直接实现单点登录,那如果是以第三方应用进行访问呢?
这时需要将我们的服务作为资源服务,作为资源服务就不会再提供验证过程,而是直接要求对方请求时携带token就可以。
- 给启动类添加注解@EnableResourceServer
- 添加配置
yaml
security:
oauth2:
client:
client-id: web
client-secret: 654321
resource:
# token信息获取和校验地址,用于资源服务器验证你这个token是否能访问我这个服务以及用户信息
token-info-uri: http://localhost:8500/sso/oauth/check_token
然后启动服务,这时直接访问会显示未授权,需要先通过密码模式获取一个token,
第一种方式:在访问地址上加上access_token 参数就可以访问了
比如这样访问:localhost:8201/book/1?access_token=ef08a607-568e-4b50-b50d-e9614c97b83c
第二种方式,在请求头种添加'Authorization',值为Bearer+一个空格+Token值:
这样资源服务器就搭建完成了,那如何对资源服务器进行自定义,希望某个用户授权了指定的Scope才可以访问此服务?
需要给资源服务编写一个配置类:
java
@Configuration
public class ResourceConfiguration extends ResourceServerConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest()
// 添加Scope 规则。token必须有这个scope才可以访问我
.access("#oauth2.hasScope('book')");
}
}
由此可见,资源服务器不必再将Security信息保存再session种了,只需要拿到token去验证服务器,就可以获得用户信息,不需要使用之前的session存储机制了。
那如何在使用RestTemplate 服务间调用时,加上token呢?
可以直接使用OAuth2RestTemplate,它会在请求时携带token。它继承自RestTemplate ,这里直接定义个Bean
java
@Configuration
public class BeanConfiguration {
@Autowired
OAuth2ClientContext oauth2ClientContext;
@Bean
// 负载均衡
//@LoadBalanced
public OAuth2RestTemplate restTemplate() {
return new OAuth2RestTemplate(new ClientCredentialsResourceDetails(),oauth2ClientContext);
}
}
具体的调用代码如下:
java
@Service
public class BorrowServiceImpl implements BorrowService {
@Autowired
private BorrowMapper borrowMapper;
@Autowired
private OAuth2RestTemplate restTemplate;
@Override
public BorrowDetail findBorrowById(int uid) {
List<Borrow> allByUid = borrowMapper.getAllByUid(uid);
// 获取用户信息 localhost:8101 改成服务名user-service
User user = restTemplate.getForObject("http://user-service/user/" + uid, User.class);
// 获取每本书的详细信息
List<Book> bookList = allByUid.stream().map(borrow -> restTemplate.getForObject("http://book-service/book/" + borrow.getBid(), Book.class))
.collect(Collectors.toList());
return new BorrowDetail(user, bookList);
}
}
那使用OpenFeign时,怎么加上token呢?
只需要加上相应的配置就可以了:
yaml
feign:
oauth2:
# 开启 oauth2支持,这样就会在请求头中加token了
enabled: true
# 同时开启负载均衡支持
load-balanced: true
总结:作为资源服务器和作为客户端时不同的!
虽然都是拿到token然后去验证服务器进行验证,然而
客户端拿到的token验证成功后,还是要保存session信息,相当于只是将登录流程换到统一的验证服务器上进行罢了
资源服务器,是由客户端进行登录验证,然后再携带token进行访问,这种模式是常见的模式!
使用jwt存储token
由于每次访问资源服务器时,资源服务器由于不知道用户信息,每次都需要请求验证服务器来获取用户信息,这样在大量请求下,验证服务器的压力会非常大。
而jwt就是解决了这个问题,使用jwt之后,token中会直接保存用户信息,这样资源服务器就不用每次都询问验证服务器了,自行解析就可以了。
jwt(json web token),它定义了一种紧凑和自成一体的方式,用于再各方之间作为json对象安全地传输信息。因为采用了数字签名,所以当被篡改后,服务器可以快速发现。jwt可以使用密钥(HMAC算法)或者RSA或ECDSA进行公钥/私钥对进行签名。
jwt令牌由3部分组成:标头(Header)、有效载荷(Payload)、签名(Signature)。传输时,会将jwt的3部分分别进行base64编码后使用小数点连接形成最终的字符串。
- 标头:包含一些元数据信息,比如jwt签名所使用的加密算法、类型,这里统一都是JWT
- 有效载荷:包括用户名称、令牌发放时间、过期时间、jwtID等,也可以自定义添加字段,这里一般存放用户信息
- 签名:首先指定一个密钥,该密钥只保存在服务器中,用户无法获取。然后使用Header中指定的算法对Header和Payload进行base64编码,编码结果通过密钥计算哈希值,这个哈希值就是签名,这个签名会用于之后验证内容是否被篡改。
这样就可以使用jwt处理token了。
客户端 -----请求token-->验证服务器 -----返回jwt令牌----->客户端 ---携带jwt令牌--->资源服务器------自己解析jwt,根据签名信息自行校验----
这里使用对称密钥进行签名。在验证服务中的Security配置类中加入如下代码:
java
@Bean
public JwtAccessTokenConverter tokenConverter() {
JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
// 这个使对称密钥,资源服务器里也要使用这个密钥
jwtAccessTokenConverter.setSigningKey("ali");
return jwtAccessTokenConverter;
}
@Bean
public TokenStore tokenStore(JwtAccessTokenConverter converter) {
return new JwtTokenStore(converter);
}
在OAuth2的配置类中添加如下代码,并重启服务:
java
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager)
// 设定配置好的AuthorizationServerTokenServices
.tokenServices(serverTokenServices())
.userDetailsService(userDetailsService);
}
@Autowired
TokenStore store;
@Resources
JwtAccessTokenConverter converter;
private AuthorizationServerTokenServices serverTokenServices(){
DefaultTokenServices tokenServices = new DefaultTokenServices();
// 允许token刷新
tokenServices.setSupportRefreshToken(true);
// 添加刚才的TokenStore
tokenServices.setTokenStore(store);
// 添加token增强,其实就是刚才的转换器,增强的意思就是添加一些自定义的数据到jwt中
tokenServices.setTokenEnhancer(converter);
return tokenServices;
}
然后就可以获取token了
现在对资源服务器进行配置,(这时资源服务器就不用连接验证服务器了)然后重启:
yaml
security:
oauth2:
resource:
jwt:
# 这里要和验证服务器的密钥一致,这样算出来的签名才能生效
key-value: ali
这时,就可以携带token直接访问资源服务器了,此时就和验证服务器没啥关系了!