在我们传统的B\S应用开发方式中,都是使用session进行状态管理的,比如说:保存登录、用户、权限等状态信息。这种方式的原理大致如下:
当然,这整个过程中,cookies和sessionid都是服务端和浏览器端自动维护的。所以从编码层面是感知不到的,程序员只能感知到session数据的存取。但是,这种方式在有些情况下,是不适用的。
当然以上的这些情况我们都有方案(如redis共享session等),可以继续使用session来保存状态。但是还有另外一种做法就是不用session了,即开发一个无状态的应用,JWT就是这样的一种方案。
JWT是一个加密后的接口访问密码,并且该密码里面包含用户名信息。这样既可以知道你是谁?又可以知道你是否可以访问应用?
下图是我用在线的JWT解码工具,解码时候的截图。注意我这里用的是解码,不是解密。
从图中,我们可以看到JWT分为三个部分:
很多的朋友看到上面的这个解码文件,就会生出一个疑问?你都把JWT给解析了,而且JWT又这么的被大家广泛熟知,它还安全么?我用一个简单的道理说明一下:
如何加强JWT的安全性?
当客户端获取到JWT之后,他就可以使用JWT请求接口资源服务了。大家可以看到在“授权流程细节”的时序图中,有一个Filter过滤器我们没有讲到,其实它和授权认证的流程关系不大,它是用来进行接口鉴权的。因为授权认证就只有一个接口即可,但是服务资源接口却有很多,所以我们不可能在每一个Controller方法中都进行鉴权,所以在到达Controller之前通过Filter过滤器进行JWT解签和权限校验。
假如我们有一个接口资源“/hello”定义在HelloWorldcontroller中,鉴权流程是如何进行的?请结合上图进行理解:
通过maven坐标引入JWT工具包jjwt
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
在application.yml中加入如下自定义一些关于JWT的配置
jwt:
header: JWTHeaderName #在请求头中的名字
secret: aabbccdd #秘钥
expiration: 3600000 #过期时间,单位毫秒
写一个Spring Boot配置自动加载的工具类。
@Data
@ConfigurationProperties(prefix = "jwt")
@Component
public class JwtTokenUtil {
private String secret;
private Long expiration;
private String header;
/**
* 生成token令牌
*
* @param userDetails 用户
* @return 令token牌
*/
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>(2);
claims.put("sub", userDetails.getUsername());
claims.put("created", new Date());
return generateToken(claims);
}
/**
* 从claims生成令牌,如果看不懂就看谁调用它
*
* @param claims 数据声明
* @return 令牌
*/
private String generateToken(Map<String, Object> claims) {
Date expirationDate = new Date(System.currentTimeMillis() + expiration);
return Jwts.builder().setClaims(claims)
.setExpiration(expirationDate)
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
/**
* 从令牌中获取用户名
*
* @param token 令牌
* @return 用户名
*/
public String getUsernameFromToken(String token) {
String username;
try {
Claims claims = getClaimsFromToken(token);
username = claims.getSubject();
} catch (Exception e) {
username = null;
}
return username;
}
/**
* 判断令牌是否过期
*
* @param token 令牌
* @return 是否过期
*/
public Boolean isTokenExpired(String token) {
try {
Claims claims = getClaimsFromToken(token);
Date expiration = claims.getExpiration();
return expiration.before(new Date());
} catch (Exception e) {
return false;
}
}
/**
* 刷新令牌
*
* @param token 原令牌
* @return 新令牌
*/
public String refreshToken(String token) {
String refreshedToken;
try {
Claims claims = getClaimsFromToken(token);
claims.put("created", new Date());
refreshedToken = generateToken(claims);
} catch (Exception e) {
refreshedToken = null;
}
return refreshedToken;
}
/**
* 验证令牌
*
* @param token 令牌
* @param userDetails 用户
* @return 是否有效
*/
public Boolean validateToken(String token, UserDetails userDetails) {
String username = getUsernameFromToken(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
/**
* 从令牌中获取数据声明,如果看不懂就看谁调用它
*
* @param token 令牌
* @return 数据声明
*/
private Claims getClaimsFromToken(String token) {
Claims claims;
try {
claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
} catch (Exception e) {
claims = null;
}
return claims;
}
}
上面的代码就是使用io.jsonwebtoken.jjwt提供的方法开发JWT令牌生成、刷新的工具类。
@RestController
public class JwtAuthController {
@Resource
private JwtAuthService jwtAuthService;
@PostMapping(value = "/authentication")
public String login(@RequestBody Map<String, String> map) {
String username = map.get("username");
String password = map.get("password");
if (StringUtils.isEmpty(username) || StringUtils.isEmpty(password)) {
return "用户名密码不能为空";
}
try{
return "token= "+jwtAuthService.login(username, password);
}catch(CustomException e){
return "用户名或密码错误";
}
}
@PostMapping(value = "/refreshtoken")
//${jwt.header}:参考value注解
public String refresh(@RequestHeader("${jwt.header}") String token) {
return "刷新后的token= "+jwtAuthService.refreshToken(token);
}
}
核心的token业务逻辑写在JwtAuthService 中
@Service
public class JwtAuthService {
@Resource
private AuthenticationManager authenticationManager;
@Resource
private UserDetailsService userDetailsService;
@Resource
private JwtTokenUtil jwtTokenUtil;
public String login(String username, String password) throws CustomException {
//使用用户名密码进行登录验证
UsernamePasswordAuthenticationToken upToken =
new UsernamePasswordAuthenticationToken( username, password );
Authentication authentication = authenticationManager.authenticate(upToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
//生成JWT
UserDetails userDetails = userDetailsService.loadUserByUsername( username );
return jwtTokenUtil.generateToken(userDetails);
}
public String refreshToken(String oldToken) {
if (!jwtTokenUtil.isTokenExpired(oldToken)) {
return jwtTokenUtil.refreshToken(oldToken);
}
return null;
}
}
因为使用到了AuthenticationManager ,所以在继承WebSecurityConfigurerAdapter的SpringSecurity配置实现类中,将AuthenticationManager 声明为一个Bean。并将"/authentication"和 "/refreshtoken"开放访问权限,如何开放访问权限,我们之前的文章已经讲过了。
@Bean(name = BeanIds.AUTHENTICATION_MANAGER)
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
当用户第一次登陆之后,我们将JWT令牌返回给了客户端,客户端应该将该令牌保存起来。在进行接口请求的时候,将令牌带上,放到HTTP的header里面,header的名字要和jwt.header的配置一致,这样服务端才能解析到。下面我们定义一个拦截器:
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Resource
MyUserDetailService myUserDetailsService;
@Resource
JwtTokenUtil jwtTokenUtil;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException
{
//从请求头中获取token
String jwtToken = request.getHeader(jwtTokenUtil.getHeader());
//token判空
if(jwtToken != null && StringUtils.isNoneEmpty(jwtToken)){
//获取用户姓名
String username = jwtTokenUtil.getUsernameFromToken(jwtToken);
//如果可以正确的从JWT中提取用户信息,并且该用户未被授权
if(username != null &&
SecurityContextHolder.getContext().getAuthentication() == null){
UserDetails userDetails = myUserDetailsService.loadUserByUsername(username);
//检验token的合法性
if(jwtTokenUtil.validateToken(jwtToken,userDetails)){
//给使用该JWT令牌的用户进行授权
UsernamePasswordAuthenticationToken authenticationToken
= new UsernamePasswordAuthenticationToken(userDetails,null,
userDetails.getAuthorities());
//放入spring security的上下文环境中,表示认证通过
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
}
//过滤器链往后继续执行
filterChain.doFilter(request,response);
}
}
在spring Security的配置类(即WebSecurityConfigurerAdapter实现类的configure(HttpSecurity http)配置方法中,加入如下配置:
//Spring Security不会创建或使用任何session。适合于接口型的无状态应用(前后端分离无状态应用),这种方式节省内存资源
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
//自定义过滤器配置
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
测试登录接口,即:获取token的接口。输入正确的用户名、密码即可获取token。
下面我们访问一个我们定义的简单的接口“/hello”,但是不传递JWT令牌,结果是禁止访问。当我们将上一步返回的token,传递到header中,就能正常响应hello的接口结果。
在我们之前实现的JWT应用中,登录认证的Controller和令牌验证的Filter是在同一个应用中的。
要想使用JWT访问资源需要
那我们可以思考一个问题,如果上面的应用部署两份形成集群应用,也就是“应用A”和“应用B”,代码是同一套代码。如果认证过程是在“应用A”获取的JWT令牌,可以访问“应用B”的接口资源么?(如下图)
答案是:可以。因为两个应用中没有在内存(session)中保存中保存任何的状态信息,所有的信息都是去数据库里面现加载的。所以只要这两个应用,使用同一个数据库、同一套授权数据、同一个用于签名和解签的secret。就可以实现“应用A”的认证、在“应用B”中被承认。 那么另外一个问题来了,对于上面的集群应用,“应用A”和“应用B”实际上是一份代码部署两份。如果“应用A”和“应用B”是真正的两套代码的部署结果呢?答案仍然是可以。前提是你的认证Controller代码和鉴权Filter代码的实现逻辑是一样的、校验规则是一样的。使用同一个数据库、同一套授权数据、同一个用于签名和解签的secret。所以JWT服务端应用可以很容易的扩展。
基于JWT的这种无状态的灵活性,它很容易实现应用横向扩展。只要具备以下条件任何JWT的应用都可以整合为一个应用集群。
基于这个条件前提,我们完全可以把认证Controller代码单独抽取出来,形成“认证服务器”。如下图所示:
或者我们还可以进一步把所有的Jwt验证鉴权Filter代码单独抽取出来,形成“服务网关”,放在接口资源的前端。当然“服务网关”的功能不只是鉴权、还要有请求转发的功能。
最后剩下的一系列的“接口资源”,实际上就是我们常说的“资源服务器”。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter
{
private UserDetailsService userDetailsService;
private PasswordEncoder passwordEncoder;
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Autowired
SecurityConfig(MyUserDetailService myUserDetailService,JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter)
{
this.userDetailsService=myUserDetailService;
this.jwtAuthenticationTokenFilter=jwtAuthenticationTokenFilter;
}
@Bean(name = BeanIds.AUTHENTICATION_MANAGER)
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
//加密密码
@Bean
public PasswordEncoder passwordEncoder()
{
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
this.passwordEncoder=passwordEncoder();
((MyUserDetailService)userDetailsService).setPasswordEncoder(passwordEncoder);
auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
//配置hello请求的访问权限
http.authorizeRequests().antMatchers("/hello").hasAuthority("hello");
//放行登录和刷新令牌的请求
http.authorizeRequests().antMatchers("/authentication").permitAll().anyRequest().authenticated();
//Spring Security不会创建或使用任何session。适合于接口型的无状态应用(前后端分离无状态应用),这种方式节省内存资源
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
//自定义过滤器配置
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
//关闭csrf防护
http.csrf().disable();
}
}