Spring Boot的应用安全
Spring Security是Spring为解决应用安全所提供的一个全面的安全性解决方案。其基于Spring AOP和Servlet过滤器,并充分利用了Spring的依赖注入和面向切面技术,启动时在Spring上下文中注入了一组安全应用的Bean,并在应用开发中提供了声明式的安全访问控制功能,使开发者可以在请求级和方法级上处理用户身份认证与鉴权,大大减少了应用开发安全处理时编写代码的工作量。
1、实现用户认证
添加Spring-boot-starter-security依赖
当Spring Security启动时,如果没有指定用户服务则会创建一个默认的user用户,登录口令则在日志中输出。
如果让Spring Security使用我们所定义的用户名和登录口令呢?需要为Spring Security提供一个UserDetailService的Bean及后续用户鉴权的AuthenticationManager。
该类继承了WebSecurityConfigurerAdapter并使用其所提供的默认配置,通过覆写configure()方法,以内存的方式增加应用用户信息的定义。在增加的用户中我们设置了用户的登录名、密码及相应的用户权限。
2、实现用于鉴权
在Spring Security中允许开发者对资源进行不同粒度的权限控制,可以限定只要是用户就可以访问任何资源,也可以设置只有用户拥有某种角色才可以访问一个资源。
要实现这种控制,还需要在SecurityConfig中覆写另外一个configure。通过antMatchers()设定,只有当用户具有ADMIN角色时才可以调用删除方法。具体表述的是”/products/“下所有的资源,在使用HTTP的DELETE方法时,都需要具有ADMIN角色,否则就会抛出访问异常。
微服务安全
在进行单体架构应用开发时最常使用的安全管理机制就是基于用户会话(Session)的认证,用户登录成功后,服务器就会将用户相关数据及权限信息存储到Session中。通常Session会直接保存在应用服务器中,仅将Session的ID返回客户端,并存储在浏览器的Cookie中,当下次访问时就可以通过该ID获取到相应的会话信息。当使用服务器集群时,可以通过Session复制或Session粘滞的方法来保证服务器可以获取到相应的Session数据。
针对微服务架构下的安全有4中解决方案:
- 单点登录(SSO)方案
- 单点登录需要每个与用户交互的服务都必须与认证服务进行通信,不但会造成重复,也会产生大量琐碎的网络流量。
- 分布式会话(Session)方案
- 通过将用户会话信息存储在共享存储中(如Redis)。
- 该方案在高可用和扩展方面都很好,但是由于会话信息保存在共享存储中,需要一定的保护机制报数数据安全。
- 客户端令牌(Token)方案
- 令牌由客户端生成,并由认证服务器签名。在令牌中会包含足够的信息,客户端在请求时会将令牌附加在请求上,从而为各个微服务提供用户身份数据。
- 此方案解决了分布式会话方案的安全性问题,但如何及时注销用户认证信息则是一个大问题,虽然可以使用短期令牌频繁地与认证服务器进行校验,但并不能彻底解决。
- 客户端令牌与API网关结合
- 通过在微服务架构中实施API网关,可以将原始的客户端令牌转换为内部会话令牌。一方面可以有效地隐藏微服务,另一方面通过API网关的统一入口可以实现令牌的注销处理。
基于令牌认证通常包含几层含义:
- 令牌是认证用户信息的集合,而不仅仅是一个无意义的ID。
- 在令牌中已经包含足够的信息,验证令牌就可以完成用户身份的校验,从而减轻了因为用户验证需要检索数据库的压力,提升了系统性能。
- 因为令牌是需要服务器进行签名发放的,所以如果令牌通过解码认证,就可以认为该令牌锁包含的信息是合法有效的。
- 服务器会通过HTTP头部中的Authorization获取令牌信息并进行检查,并不需要服务端存储任何信息。
- 通过服务器对令牌的检查机制,可以将基于令牌的认证使用在基于浏览器的客户端和移动设备的App或第三方应用上。
-
基于OAuth 2.0的认证
OAuth是一个开放的、安全的用户认证协议,允许用户让第三方应用访问该用户在某一网站上存储的私密的资源,而无须将用户名和登录口令提供给第三方应用。授权的第三方应用只能在特定的时间段内访问特定的资源,而非所有内容。每次授权的令牌只能针对一个第三方应用,因此可以认为OAuth是一个非常安全的用户认证/授权协议。
2010年4月OAuth发布了2.0版本,并在2012年10月正式发布为RFC6749。OAuth 2.0对认证流程进行了简化,更加关注客户端开发者的简易型,并为Web应用、桌面应用、手机App等提供专门的认证流程。但OAuth 2.0并不向下兼容OAuth 1.0。
基于OAuth 2.0的用户认证具有以下优点。 简单:不管是OAuth 2.0服务的提供商还是应用开发商,都易于理解与使用。
- 安全:用户认证过程中并不涉及用户秘钥等信息,使认证方案更安全、更灵活。
- 开放:OAuth 2.0只是一个用户认证安全协议,所以任务服务提供商都可以基于该协议来实现,任何软件开发商都可以使用该协议完成用户认证流程。
1、OAuth 2.0授权流程
(1)用户打开客户端以后,客户端要求用户给予授权。
(2)用户同意给予客户端授权。
(3)客户端使用上一步获得的授权,向认证服务器申请令牌。
(4)认证服务器对客户端进行认证,确认无误后同意发放令牌。
(5)客户端使用令牌,向资源服务器申请获取资源。
(6)资源服务器确认令牌无误,同意向客户端开放资源。
从OAuth 2.0的授权流程中可以看到基于OAuth 2.0的授权涉及下面4种角色。
- 资源拥有者:资源拥有者是对资源具有授权能力的人,通常也就是我们所说的用户。
- 客户端/第三方应用:客户端/第三方应用代表资源所有者对资源服务器发送访问受保护资源的请求。
- 资源服务器:资源所在的服务器,也就是受安全认证保护的资源。
- 授权服务器:就是通常所说的认证服务器,为客户端应用程序提供不同的访问令牌。授权服务器可以和资源服务器在同一服务器上,也可以独立部署。
2、客户端授权模式
从前面的授权流程中可以看到,客户端必须得到用户的授权,才能从授权服务器中获得访问令牌。在OAuth 2.0中定义了4中客户端授权模式,分别是授权码模式(Authorization Code)、简化模式(Implicit)、密码模式(Resource Owner Password Credentials)和客户端模式(Client Credentials)。其中,最后一种客户端模式是指客户端以自己的名义而不是用户的名义向授权服务器进行认证,实际上并不存在用户授权问题,因此我们下面不再讨论该模式。
- 授权码模式(Authorization Code)
授权码模式是OAuth 2.0中功能最完整、流程最严密的授权模式。
(1)用户访问客户端,客户端将用户引导到授权服务器上。
(2)用户选择是否同意给客户端授权。
(3)如用户同意授权,授权服务器将重定向到客户端事先指定的地址,同时附加上一个授权码(Token)。
(4)客户端收到授权码后,同时附加上需要重定向的页面(如果有的话),经由客户端后台授权服务器申请令牌。
(5)授权服务器校验授权码后,想客户端发送访问令牌(Access Token)和更新令牌(Refresh Token)并重新定向到上一步指定的页面。
- 简化模式(Implicit)
简化模式是指不通过客户端的后台服务器来获取访问令牌,这里的客户端通常是浏览器,客户端直接通过脚本语言(一般是JavaScript)来完成向授权服务器申请访问令牌的操作。具体流程如下:
(1)用户访问客户端,客户端将用户引导到授权码服务器上,并附加认证成功或失败时需要重定向的URL。
(2)用户选择是否同意给客户端授权。
(3)如果用户同意授权,那么授权服务器根据user-agent中的数据进行验证,验证通过后将用户重定向到之前所指定的地址,同时在所重定向的地址中附加一个相应访问令牌的值;
(4)浏览器将返回的信息保存在本地,然后向资源服务器发出申请,但不包括访问令牌。
(5)资源服务器返回一个网页,通过在该网页中会包含一段代码,该代码可以获取之前返回的访问令牌。
(6)浏览器执行上一步中获得的脚本,并获取到访问令牌。
(7)浏览器将解析到的访问令牌发送给客户端。
- 密码模式(Resource Owner Password Credentials)
密码模式是指客户端通过用户提供的用户名和密码信息,直接通过授权服务器来获取授权。在这种模式下,用户需要把自己的用户名和密码提供给客户端,但是客户端不得存储这些信息。该模式只有在用户对客户端高度信息的情况下或者同一个产品系列中,在实际生产中应避免使用这种授权模式。该模式的授权流程如下:
(1)用户向客户端提供相应的用户名和密码
(2)客户端通过用户提供的用户名和密码向授权服务器请求访问令牌。
(3)授权服务器确认后,返回访问令牌给客户端。
**3、使用OAuth 2.0完成用户认证及授权
在接下来的示例中,首先会搭建一个基于OAuth 2.0的认证服务器,完成用户认证及授权功能。然后在这个基础上完成商品微服务权限认证的迁移。最后为了能够在示例中了解微服务之间调用时认证信息是如何进行传递的,对用户微服务和前面所建立的API网关服务器(Zuul)进行改造,增加权限控制处理,最终完成基于OAuth 2.0认证体系的整体改造。
下面先搭建认证服务器。基于OAuth 2.0协议的认证服务器也是一个标准的Spring Boot应用。搭建时只需要在一个空白的Spring Boot应用中增加下面两个依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
</dependency>
这两个依赖中的spring-cloud-security将会给工程中增加安全认证所需要的基础库,而spring-security-oauth2则是在上述的基础上增加OAuth 2.0认证体系所需要的库。接下面就是编写应用的引导类,代码如下:
@SpringBootApplication
@EnableAuthrizationServer
public class Application {
public static void main(String[] args) {
new SpringApplicationBuilder(Application.class).web(true).run(args);
}
}
在认证服务器的引导类中,最重要的就是增加@EnableAuthorizationServer注解。该注解一是将当前应用作为一个OAuth任务服务,二是为应用增加了一些列的REST端点,从而提供一个标准的OAuth 2.0的实现。
认证服务端的框架搭建好之后就可以创建OAuth客户端的管理了。开发过与OAuth集成应用的读者都知道,当需要和第三方认证集成时通常要提供一个ClientID(或AppID)和ClientSecret(或AppSecret)用来进行认证。对于我们所要搭建的OAuth认证服务器也一样,只有认证后的应用才可以使用所提供的用户认证服务。具体实现方式就是需要扩展Spring的AuthorizationServerConfigurerAdapter,并覆写其中的configure()方法。具体代码如下:
@Configuration
public class OAuthConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private UserDetailService userDetailService;
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
// 通过内存方式设置认证的客户端
clients.inMemory()
.withClient("springclouddemo")
.secret("scdsecret")
.authorizedGrantTypes("refresh_token", "password", "client_credentials")
.scopes("webclient", "mobileclient");
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
// 设置用户及认证的实现
endpoints.authenticationManager(authenticationManager)
.userDetailService(userDetailService);
}
}
在上面的代码中我们使用内存方式直接注册了一个ClientID为springclouddemo,客户端的secret为scdsecret,并且授权该客户端可以使用refresh_token、password、client_credentials等的客户端授权方式。当客户端创建完毕后,就可以完成具体用户的认证和权限授权处理了,这个功能的实现和之前商品微服务类似,所以就不再赘述了。具体代码如下:
@Configuration
@Order(org.springframework.boot.autoconfigure.security.SecurityProperties.ACCESS_OVERRIDE_ORDER)
public class OAuthWebSecurityConfigurer extends WebSecurityConfigurerAdapter {
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
@Bean
public UserDetailsService userDetailsServiceBean() throws Exception {
return super.userDetailsServiceBean();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 通过内存方式设置用户信息
auth.inMemoryAuthentication()
.withUser("zhangsan")
.password("password")
.roles("USER")
.and()
.withUser("superAdmin")
.password("superPwd")
.roles("USER", "ADMIN");
}
}
和前面一样,这里创建了两个用户,并分别赋予了不同角色。此外,还需要说明的是,这里需要调整WebSecurityConfigurerAdapter和ResourceServerConfigurerAdapter的拦截顺序。其中,WebSecurityConfigurerAdapter用于保护OAuhth相关端点,同时用于用户登录;而ResourceServerConfigurerAdapter则用于保护OAuth要开放的资源,同时主要作用于客户端及令牌的认证。如果不调整顺序的话,可能会造成没有权限访问的异常。此外,我们也可以通过在上面两个Adapter中配置需要开放的资源验证路径来达到目的。
最后还有一个最重要的功能需要实现,就是认证服务器需要提供一个可以根据访问令牌获取认证用户相关信息的端点,通常为/auth/user。这个功能借助Spring MVC和Spring Security可以轻松完成,具体实现代码如下(另外不要忘记增加@RestController注解):
@RequestMapping(value = "{/user}", produces = "application/json")
public Map<String, Object> user(OAuth2Authentication user) {
Map<String, Object> userInfo = new HashMap<>();
userInfo.put("user", user.getUserAuthentication().getPrincipal());
userInfo.put("authorities", AuthorityUtils.authorityListToSet(user.getUserAuthentication().getAuthorities()));
return userInfo;
}
到这里,基于OAuth 2.0协议的认证服务已经搭建完毕了。运行后,通过请求http://localhost:8900/auth/oauth/token,就可以获取到一个访问令牌了
然后就可以使用该访问令牌通过认证服务所提供的用户信息端点http://localhost:8900/auth/user获取相关认证信息了。
既然认证服务器已经成功运行了,接下来就可以对商品服务和用户服务进行改造了。下面以商品微服务改造为示例。
首先需要在商品微服务中将原来的安全依赖替换成如下依赖:
<! -- 删除下面所示的安全依赖 -- >
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<! -- 增加新的安全依赖 -- >
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
</dependency>
然后在配置文件(application.properties)中增加获取用户认证信息地址的配置如下:
security.oauth2.resource.user-info-uri=http://localhots:8900/auth/user
在引导类中增加@EnableResourceServer注解,该注解告诉Spring Boot这应用是一个OAuth保护资源,在访问时Spring Security就会从请求信息中检查是否有合法的访问令牌(Access Token),并从该令牌所配置的认证用户信息端点中获取相应的用户信息,获取成功后Spring Security就会将用户信息存放在Spring安全上下文中,以便后面对用户进行鉴权。
接下来从将原来的安全配置类SecurityConfig删除,并替代为下面新的安全配置类:
@Configuration
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated();
}
}
到这里就完成对商品微服务的安全改造了,打包运行,如果此时直接访问就会抛出未认证异常。我们将上一步在认证服务器所得到的访问令牌设置到Header的Authorization中,然后再次访问,此时就可以得到正确的结果了。
对于用户微服务的安全改造和前面的改造一致,这里就不重复了。当改造用户微服务之后如果再访问商品评论服务会发现依然会报未认证异常的错误,这是由于通过商品微服务调用用户服务时,并没有将认证信息传递给用户服务造成的。那么如何传递该认证信息呢?我们平常在调用其他微服务时是通过默认的RestTemplate进行调用,该类并不会自动处理OAuth认证的相关信息,需要将微服务调用更改成使用OAuth2RestTemplate类,该类在发起请求时可以自动将原来请求中所包含的访问令牌进行转发。具体代码如下:
// 在Application.java中增加OAuth2RestTemplate的配置
@Bean
public OAuth2RestTemplate oauth2RestTempate(OAuth2ClientContext oauth2ClientContext, OAuth2ProtectedResourceDetails details) {
return new OAuth2RestTemplate(details, oauth2ClientContext);
}
// 将调用用户服务中的RestTemplate更改为OAuth2RestTemplate
@Component
public class UserServiceImpl implements UserService {
@Autowired
private OAuth2RestTemplate restTemplate;
@Override
public UserDto load(Long id) {
UserDto userDto = this.restTemplate.getForEntity("http://localhost:2100/users/{id}", UserDto.class, id).getBody();
return userDto;
}
}
此时,再访问商品评论服务就可以得到正确的结果了。
**4、整合API网关服务
一般而言,商品微服务不会像上面直接访问用户服务,而是通过之前我们所搭建的统一API网关服务来进行访问。代码如下:
@Override
public UserDto load(Long id) {
UserDto userDto = this.restTemplate.getForEntity("http://localhost:8280/userservice/users/{id}", UserDto.class, id).getBody();
return userDto;
}
很不幸,这里的代码更改并没有为我们带来预想的结果,而是抛出了用户未认证异常的错误。这是为什么?前面在介绍Zuul配置时有一个敏感Header的设置,默认情况下会过滤Cookie、Set-Cookie和Authorization这3个。当商品微服务通过Zuul调用用户微服务时就会将OAuth2RestTemplate转发的Authorization给过滤掉,所以用户微服务将收不到用户访问令牌,从而抛出未认证异常错误。知道原因后就容易改正了,只需要在Zuul服务器中重设一下敏感Header就可以了。
zuul.sensitiveHeaders=Cookie, Set-Cookie
重启服务再次访问,结果就正确了。
当我们使用Zuul实施了统一的API网关服务之后,还有一个更简便的方式来启动OAuth2的单点登录,就是在Zuul服务器中增加一个@EnableOAuth2Sso注解,并在配置文件中增加类似如下的配置:
spring.oauth2.client.clientId=1231231
spring.oauth2.client.clientSecret=12321
spring.oauth2.client.accessTokenUri=https://github.com/login/oauth/access_token
spring.oauth2.client.userAuthorizationUri=https://github.com/login/oauth/authorize
spring.oauth2.client.clientAuthenticationScheme=from
spring.oauth2.resource.userInfoUri=https://api.github.com/user
spring.oauth2.resource.preferTokenInfo=false
这样Zuul服务器就可以自动登录到单点认证服务器(如上面所设置的GitHub),并转发到用户登录界面,用户认证后就可以获取到相应的访问令牌,并将令牌传递到下游服务。如果下游的服务在引导类中注解了@EnableResourceServer,那么就会从Header中获取该令牌。此时也不需要再在Zuul配置文件中指定敏感Header设置了。
基于JWT的认证
JWT是基于JSON的一个开放标准-RFC7519,其代表的是一种紧凑的、URL安全的、能够在网络应用间传输的声明。当我们使用在认证场景下时,JWT可以用来在客户端和资源服务器间传递认证用户的身份信息,以便于从资源服务器获取某个资源。同时,为了资源服务器认证及业务需要,也可以在令牌中增加一些额外信息。一个标准的JWT通常看起来如下:
其由3段信息构成,格式为header.payload.signature。这3段信息分别说明如下:
- 头部(header):头部描述了该JWT的最基本信息,如类型、签名算法等。
- 载荷(payload):载荷存放了令牌有效信息。一个有效信息通常包含3个部分。
- 标准中注册声明:这部分是对令牌中的一些标准属性信息进行声明,标准规定这是一个建议不强制。常用的属性信息有:
- iss:令牌的签发者
- sub:令牌所面向的用户
- aud:接收令牌的一方
- iat:令牌签发时间
- exp:令牌过期时间
- nbf:定义令牌有效起始时间,在该时间之前是不可用的
- jti:令牌唯一身份标识,主要用来作为一次性令牌,避免重放攻击
- 公共声明:该部分一般是用户相关信息或业务需要的其他信息,对于该部分的信息没有限制,但不建议将敏感信息放在该部分,因为客户端可以对该部分进行解密。
- 私有声明:该部分是提供者和消费者所共同定义的声明,也是使用Base 64进行加/解密,因此也不建议存放敏感信息。
- 标准中注册声明:这部分是对令牌中的一些标准属性信息进行声明,标准规定这是一个建议不强制。常用的属性信息有:
- 签名(signature):将头部和载荷使用Base 64编码后,通过所使用的加密方法进行签名,签名后的结果放在这部分内容中。
以上3段内容都是JSON对象,并采用Base 64编码,然后将编码后的内容使用点”.”连接在一起就形成了一个标准的JWT。
当使用JWT用户认证时,客户端与资源服务器及认证服务器之间的交互流程如下:
(1)客户端调用认证服务器的登录接口/获取Token接口,传入用户名密码。
(2)认证服务器确认用户名密码,并创建JWT返回给客户端。
(3)客户端获取到JWT后进行缓存。
(4)客户端请求资源服务器,并在请求的HTTP头部中附加JWT。
(5)资源服务端对JWT进行校验,校验通过后,向客户端返回相应的资源和数据。
使用JWT进行认证处理具有以下优点:
- JWT是基于令牌的,将用户状态分散到了客户端中,服务器端无状态,减轻了服务器的压力,提升了性能;
- JWT具有严格的结构化,其自身就包含了关于认证用户的相关信息,一旦校验成功那么资源服务器就无须再去认证服务器验证信息的有效性;
- JWT中的载荷可以支持定制化,因此开发者可以根据业务需要进行扩展定义,如添加用户是否是管理员、用户所在分桶等信息,从而满足业务需要;
- JWT体积小,便于传输,并且在传输方式可以支持URL/POST参数或者HTTP头部等方式传输,因而可以支持多种客户端,不仅仅是Web。
- JWT使用JSON格式,对跨语言的支持非常好;
- JWT支持跨域,使单点登录的开发更容易。
1、改造认证服务支持输出JWT
Spring Security在JWT上提供了开箱即用的支持。首先需要在认证服务器中引入spring-security-jwt的依赖,pom.xml中配置如下:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-jwt</artifactId>
</dependency>
此外,对于接下来需要改造的用户微服务和商品微服务都需要增加该依赖,下面就不重复了。配置好上面的依赖之后,就可以告诉认证服务器将所生成的访问令牌转换成JWT令牌了。首先需要定义一个令牌转换器,代码如下:
@Configuration
public class JWTTokenConfig {
@Autowired
private ServiceConfig serviceConfig;
@Bean
publoc TokenStore tokenStore() {
return JwtTokenStore(jwtAccessTokenConverter());
}
// 定义默认的令牌处理服务
@Bean
@Primary
public DefaultTokenServices tokenServices() {
DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
defaultTokenServices.setTokenStore(tokenStore());
defaultTokenServices.setSupportRefreshToken(true);
return defaultTokenServices;
}
// 定义令牌的转换器
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
// 这里采用对称加密方式,并从配置文件中获取秘钥
converter.setSigningKey(serviceConfig.getJwtSigningKey());
return converter;
}
// 定义JWT令牌增强器,用来对载荷进行自定义扩展
@Bean
public TokenEnhancer jwtTokenEnhancer() {
return new JWTTokenEnhancer();
}
}
上面的代码中最主要的就是创建了一个JwtAccessTokenConverter的Bean。这里我们设置了一个JWT加密所使用的秘钥。对于JWT的加密,支持对称加密和非对称加密,非对称加密需要在服务端生成一个秘钥对(公钥和私钥),每一个需要使用JWT的客户端都可以获取到公钥,并可以使用公钥对JWT进行解密和认证。对于公钥和私钥的生成需要一个证书,相对来说比较麻烦,示例中直接采用对称加密的方式进行演示,有兴趣的读者可以使用JDK自导的keytool工具完成,这里就不再详细介绍了。
对于对称加密,只需要配置一个秘钥就可以了,但需要注意的是无论服务器端还是客户端需要使用到JWT的项目都需要知道该密码。作为示例,这里就直接将秘钥存放在配置文件中,但在实际生产环境中建议开发者还是将秘钥统一存放在配置服务器中,不要在每个项目中进行配置。
jwt.signing.key = springcloud-dong
该秘钥通过Spring Boot的自定义扩展属性,通过@Value(“${jwt.signing.key}”)注解直接将值注入到ServiceConfig中。同样,在后续用户微服务、商品微服务及Zuul中都需要配置该属性。
当创建了JwtAccessTokenConverter Bean之后就可以更改OAuth的配置,使其在生成访问令牌时可以使用该转换器进行转换,这个需要调用前面的OAuthConfig.configure(),具体修改后的代码如下:
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
// 增加一个令牌增强配置
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Arrays.asList(this.jwtTokenEnhancer, this.jwtAccessTokenConverter));
// 定义如何将OAuth生成的访问令牌转换为JWT令牌
endpoints.tokenStore(this.tokenStore)
.accessTokenConverter(this.jwtAccessTokenConverter)
.tokenEnhancer(tokenEnhancerChain)
.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService);
}
在上面的代码中我们通过AuthorizationServerEndpointsConfigurer将访问令牌的转换器设置为之前所创建的JwtAccessTokenConverter,这样所生成的令牌就是一个标准的JWT令牌了。重新编译运行,当通过Postman再次访问http://localhost:8900/auth/oauth/token端点时,所生成的访问令牌。
当使用上面所得到的令牌并通过访问http://localhost:8900/auth/user就可以获取到相应的认证用户及其授权信息:
或许有的读者回想,上面那么多字符里面是不是真的像之前所说的那样包含了头部、载荷及签名这些数据呢?此时,我们可以借助一个在线工具https://jwt.io来对前面的令牌数据进行解码并进行校验。
从截图中可以看到解码后的数据分为了头部、载荷及签名数据,并格式化为JSON格式显示,解码出来的载荷部分包含了用户名、用户权限、过期时间等信息。当我们将所使用的秘钥springcloud-dong输入到VERIFY SIGNATURE中的输入框后,在最下面就可以看到签名验证成功的提示。
前面提到的JWT允许开发者进行私有属性的扩展。那么到底怎么扩展的呢?首先看一下JwtTokenConfig类的定义,这里有一个Bean声明:
@Bean
public TokenEnhancer jwtTokenEnhancer() {
return new JWTTokenEnhancer();
}
// JWTTokenEnhancer的代码
public class JWTTokenEnhancer implements TokenEnhancer {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
Map<String, Object> additionalInfo = new HashMap<>();
// 这里添加了一个shopId自定义属性,并将值默认设置为cd826
additionalInfo.put("shopId", "cd826");
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
return accessToken;
}
}
这段代码非常简单,就是实现了令牌增强接口TokenEnhancer,在令牌中增加了一个shopId的属性,并将值固定设置为cd826。然后在OAuthConfig中通过下面的代码将该增强器设置到访问令牌增强器链中,这样当生成了一个访问令牌时,就会调用以下增强代码对访问令牌中的属性进行扩展了。代码如下:
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Arrays.asList(this.jwtTokenEnhancer, this.jwtAccessTokenConverter));
2、在Zuul中对JWT进行解析
前面讲过JWT具有严格的结构化,其自身包含了关于认证用户相关的消息,一旦校验成功,资源服务器就不需要再次进行认证,可以直接使用这些信息。从前面在线工具的解析中可以看到,从JWT中已经可以解析到用户名、用户权限及开发者自己所扩展的店铺ID信息,那么当开发者使用时如何获取到这些解析后的数据呢?一个比较好的思路就是通过所搭建的API网关,由该网关统一进行解析,解析之后就可以将这些数据转发给下游各微服务之间使用,下面就按照这个思路来一步步实现。
对于JWT的解析,我们需要在Zuul项目中增加一个工具包。pom.xml中新增配置如下:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
一旦引入上面的依赖之后,我们就可以编写解析代码了。读者是否还记得Zuul中的过滤器?这里就是最佳使用的地方。所编写的过滤器代码如下:
@Component
public class JWTTokenFilter extends ZuulFilter {
private static final int FILTER_ORDER = 1;
@Autowired
private FilterUtils filterUtils;
@Autowired
private ServiceConfig serviceConfig;
// 过滤器类型:pre
@Override
public String filterType() {
return FilterUtils.PRE_FILTER_TYPE;
}
// 过滤器顺序:1
@Override
public int filterOrder() {
return FILTER_ORDER;
}
// 始终对请求进行过滤
@Override
public boolean shouldFilter() {
return true;
}
public Object run() {
this.parseJWTToken();
return null;
}
// 使用JJWT解析JWT Token中的信息
private void parseJWTTOken() {
if (null == filterUtils.getAuthToken()) {
return;
}
String authToken = filterUtils.getAuthToken().replace("bearer ", "");
try {
// 这里不要忘记在配置文件中配置秘钥
Claims claims = Jwts.parser()
.setSigningKey(serviceConfig.getJwtSigningKey())
.getBytes("UTF-8")
.parseClaimsJws(authToken).getBody();
// 对JWT中包含的信息进行解析
filterUtils.setShopId((String) claims.get("shopId"));
filterUtils.setUserId((String) claims.get("user_name"));
} catch (Exception e) {
e.printStackTrace();
}
}
}
这是一个pre类型的过滤器,并且始终对所有请求进行过滤。在对JWT令牌的解析中,我们判断HTTP头部中是否存在Authorization属性,如果有纳秒尝试使用JJWT所提供的工具进行解析,解析出来的结果通过FilterUtils存放在Zuul请求的头部中。这里仅解析出需要的shopId和user_name两个属性,其他的暂时不必关心。另外,FilterUtils的代码这里就不详细列出来了,读者可以从本书源码中获取。
注意:为了保证能够正确解析出来,一定要在配置文件中将秘钥配置进去。
3、改造商品微服务
在单体架构开发时我们常常会创建一个类UserContext,该类中包含了当前用户的上下文信息,同时会增加一个过滤器对所有请求初始化用户上下文信息。同样的,当开发者使用Zuul对JWT令牌统一进行解析并转发到下游应用后,就可以在过滤器中获取这些信息。在商品微服务中增加用户上下文处理的代码如下:
@Component
public class UserContextFilter implements Filter {
protected Logger logger = LoggerFactory.getLogger(this.getClass());
@Override
public void doFilter(ServletRquest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throw IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
// 从请求的头部解析出用户ID、店铺ID及访问令牌,并根据这些信息构建用户上下文
String userId = request.getHeader(UserContext.USER_ID);
String authToken = request.getHeader(UserContext.AUTH_TOKEN);
String shopId = request.getHeader(UserContext.SHOP_ID);
UserContext.setUserId(userId);
UserContext.setAuthToken(authToken);
UserContext.setShopId(shopId);
this.logger.debug("Current shop.id = {}", UserContext.getShopId());
filterChain.doFilter(request, servletResponse);
}
}
// 用户上下文类
@Data
public class UserContext {
public static final String AUTH_TOKEN = "Authorization";
public static final String USER_ID = "scd-user_id";
public static final String SHOP_ID = "scd-shop_id";
private static final ThreadLocal<String> authToken = new ThreadLocal<String>();
private static final ThreadLocal<String> userId = new ThreadLocal<String>();
private static final ThreadLocal<String> shopId = new ThreadLocal<String>();
}
编译并重启,然后通过Postman访问http://localhost:8280/productservice/products/3,就会发现在控制台中的日志输出。另外,如果通过商品微服务的端口直接访问,因为没有进行解析,所以会输出Current shop.id = null。
如果此时通过Postman访问http://localhost:8280/productservice/products/3/comments,很不幸会得到一个异常信息,这是为什么呢?前面说过,当在商品微服务中使用OAuth2RestTemplate进行微服务请求时,会自动转发OAuth认证服务器所生成的访问令牌,但是当将访问令牌转换成JWT令牌时,OAuth2RestTemplate就会拒绝转发。此时需要手工做一些调整才可以。首先需要在商品微服务中增加一个用户请求拦截器用来对JWT令牌进行转发。代码如下:
public class JWTOAuthTokenInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
HttpHeaders headers = request.getHeaders();
// 将JWT令牌设置到Header中
headers.add(UserContext.AUTH_TOKEN, UserContext.getAuthToken());
return execution.execute(request, body);
}
}
然后修改调用微服务所使用的RestTemplate,代码如下:
@Bean
@Primary
public RestTemplate getCustomRestTemplate() {
RestTemplate template = new RestTemplate();
List interceptors = template.getInterceptors();
// 在RestTemplate拦截器链中增加JWT令牌转发拦截器
if (interceptors == null) {
template.setInterceptors(Collections.singletonList(new JWTOAuthTokenInterceptor()));
} else {
interceptors.add(new JWTOAuthTokenInterceptor());
template.setInterceptors(interceptors);
}
return template;
}
同时需要先将原来的OAuthRestTemplate代码删除,然后修改对用户微服务的访问,将:
@Autowired
private OAuth2RestTemplate restTemplate;
修改为:
@Autowired
private RestTemplate restTemplate;
编译并重启,然后再访问商品评论服务,就会发现已经可以正常访问了。
同样,如果在用户微服务中需要使用解析后的JWT令牌信息,或者还需要调用其他微服务,那么就需要按照修改商品微服务的方式进行同样的修改,这里就不再赘述了。
从上面的示例来看,在微服务架构下,通过将OAuth与JWT结合,并通过API网关统一进行管控,已经基本上满足微服务及微服务之间的访问的用户认证及鉴权需求。此外,JWT更加轻巧,客户端也非常容易支撑,而且对服务端基本上没有什么压力。因此,在搭建微服务架构的应用时安全方案可以首先考虑JWT方案。
但这并不是说JWT方案没有缺点,以下几点是在实施JWT安全方案时需要仔细考虑的问题。
- JWT令牌注销:由于JWT令牌存储在客户端,当用户注销时可能由于有效时间还没有到,造成客户端还会存储,这时候需要开发者能够有效防止注销后令牌的访问,开发者可以借助API网关来实现。另外,采用短期令牌也是一个不错的解决方案。
- JWT令牌超长:由于JWT允许开发者对令牌进行自定义扩展,如果在JWT的载荷中包含的信息过多,就会导致客户端每次的请求头部信息变长,从而影响请求速度。
- 避免称为系统新瓶颈:由于API网关服务会对认证服务器进行访问及鉴权处理,有可能会形成系统的新瓶颈。
- 需要有效防范XSS攻击:由于JWT存储在客户端,最有可能引发XSS攻击,因此当使用JWT时必须做出有效的防范。