前面讲解过什么是SSO,OAuth2相关的一系列的知识点,今天讲解一下JWT的相关知识。
JWT的全称为Json Web Token (JWT),是目前最流行的跨域认证解决方案,是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519)。而且该Token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。我们在前面讲解过SSO,知道通过CAS去实现SSO的应用是比较重和庞大的,而通过JWT去实现SSO不失为一个好的方案。
JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该Token也可直接被用于认证,也可被加密。更多详情也可以参考JWT的官网地址。
既然使用JWT那么我们就要知道它与传统的Session的区别,为啥我们要使用它?带着这个疑问我们来进一步探究。
因为Http协议本身是一种无状态的协议,因此这就意味着如果用户向应用提供了用户名和密码来进行用户认证,那么下一次请求时,用户还要再一次进行用户认证才行,因为根据Http协议,应用并不能知道是哪个用户发出的请求,所以为了让我们的应用能识别是哪个用户发出的请求,我们只能在服务器存储一份用户登录的信息,这份登录信息会在响应时传递给浏览器,保存为Cookie,以便下次请求时发送给我们的应用,这样我们的应用就能识别请求来自哪个用户了,这就是传统的基于Cookie-Session认证。
基于Token的身份验证是无状态的,服务器不需要记录哪些用户已经登录或者哪些JWT已经处理。每个发送到服务器的请求都会带上一个Token,服务器利用这个Token检查确认请求的真实性。
Token通常以Bearer { JWT }
的形式附加在已验证的请求头中,但是也可以用POST请求体或者问句参数进行传递。
相对于传统的Session认证方式,JWT天生支持无状态,同时它也更更安全,通用性JSON,扩展强,支持跨域访问等。
JWT是由三段信息构成的,将这三段信息文本用.链接一起就构成了JWT字符串。如下所示:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.UQmqAUhUrpDVV2ST7mZKyLTomVfg7sYkEjmdDI5XF8Q
第一部分我们称它为头部(header),第二部分我们称其为载荷(payload,类似于飞机上承载的物品),第三部分是签证(signature)。
JWT的头部承载两部分信息:
完整的头部就像下面这样的JSON:
{
'typ': 'JWT',
'alg': 'HS256'
}
然后将头部进行base64加密(该加密是可以对称解密的),构成了第一部分
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
payload是JWT的组成部分的第二块,载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分:
公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息。但不建议添加敏感信息,因为该部分在客户端可解密。
私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。
比如这里我们定义一个payload:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
然后将其进行base64加密,得到JWT的第二部分:
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
JWT的第三部分是一个签证信息,这个签证信息由三部分组成:
这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符串(头部在前),然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了JWT的第三部分。
UQmqAUhUrpDVV2ST7mZKyLTomVfg7sYkEjmdDI5XF8Q
密钥secret是保存在服务端的,服务端会根据这个密钥进行生成token和验证,所以需要保护好。
最后一步签名的过程,实际上是对头部以及载荷内容进行签名。一般而言,加密算法对于不同的输入产生的输出总是不一样的。对于两个不同的输入,产生同样的输出的概率极其地小。所以,我们就把“不一样的输入产生不一样的输出”当做必然事件来看待吧。
所以,如果有人对头部以及载荷的内容解码之后进行修改,再进行编码的话,那么新的头部和载荷的签名和之前的签名就将是不一样的。而且,如果不知道服务器加密的时候用的密钥的话,得出来的签名也一定会是不一样的。
一般是在请求头里加入Authorization,并加上Bearer标注:
fetch('api/user/1', {
headers: {
'Authorization': 'Bearer ' + token
}
})
这里主要讲解一下在Spring中使用JWT,Java版本的JWT实现。
除了上面的JWT的Java实现版本,还有其他版本的,比如java-jwt。
首先引入依赖如下:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.0</version>
</dependency>
在Spring中使用JWT主要使用两种方式:
1、在Spring-MVC中通过自定义filter可以获取到每次请求在请求拦截验证JWT签发的Token。
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 验证token
//传递给后面的api
filterChain.doFilter(request, response);
}
}
@Bean
public FilterRegistrationBean jwtFilter() {
FilterRegistrationBean registrationBean = new FilterRegistrationBean();
JwtAuthenticationFilter filter = new JwtAuthenticationFilter();
registrationBean.setFilter(filter);
return registrationBean;
}
2、在Spring MVC中通过自定义HandlerInterceptor,在WebMvcConfigurer中进行配置。
public class BaseSecurityInterceptor extends HandlerInterceptorAdapter {
private Logger logger = LoggerFactory.getLogger(BaseSecurityInterceptor.class);
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
super.preHandle(request, response, handler);
// 验证token
return true;
}
}
说了那么多,还是直接实战吧,可以通过两种方式来做,一种是通过白名单/黑名单进行过滤,另一种是通过在Rest请求层通过注解获取/过滤特定注解。
首先创建一个Spring Boot应用JwtApplication,在config下面添加我们需要的配置JwtAuthenticationFilter,如下:
package net.anumbrella.spring.jwt.config;
import lombok.extern.slf4j.Slf4j;
import net.anumbrella.spring.jwt.util.JwtUtil;
import net.anumbrella.spring.jwt.util.ResponseUtil;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.PathMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import static java.util.stream.Collectors.toList;
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final PathMatcher PATH_MATCHER = new AntPathMatcher();
private final static ConcurrentMap<String, Boolean> CACHE_IS_FILTER_PATH = new ConcurrentHashMap<>();
private final List<String> jwtFilterWhitelist;
private final List<String> jwtFilterBlacklist;
public JwtAuthenticationFilter(JwtProperties jwtProperties) {
this.jwtFilterWhitelist = Arrays.stream(jwtProperties.getJwtFilterWhitelist().split(",")).map(String::trim).collect(toList());
this.jwtFilterBlacklist = Arrays.stream(jwtProperties.getJwtFilterBlacklist().split(",")).map(String::trim).collect(toList());
}
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
try {
if (isFilterUrl(httpServletRequest)) {
// 获取请求头信息authorization信息
final String authHeader = httpServletRequest.getHeader(JwtUtil.AUTH_HEADER_KEY);
log.info("## authHeader = {}", authHeader);
if (StringUtils.isEmpty(authHeader) || !authHeader.startsWith(JwtUtil.TOKEN_PREFIX)) {
ResponseUtil.renderResponse(httpServletResponse, HttpServletResponse.SC_UNAUTHORIZED, "用户未登录,请先登录");
return;
}
// 获取token
final String token = authHeader.substring(7);
// 验证token
if(!JwtUtil.validateToken(token)){
ResponseUtil.renderResponse(httpServletResponse, HttpServletResponse.SC_UNAUTHORIZED, "token认证失败,请重新登录");
}
httpServletRequest = new RequestWrapper(httpServletRequest, JwtUtil.getUserId(token));
}
} catch (Exception e) {
ResponseUtil.renderResponse(httpServletResponse, HttpServletResponse.SC_UNAUTHORIZED, "登陆已经失效,请重新登录");
return;
}
filterChain.doFilter(httpServletRequest, httpServletResponse);
}
public boolean isFilterUrl(HttpServletRequest request) {
String uri = request.getServletPath();
if (CACHE_IS_FILTER_PATH.containsKey(uri)) {
return CACHE_IS_FILTER_PATH.get(uri);
}
boolean flag = isFilter(uri);
CACHE_IS_FILTER_PATH.putIfAbsent(uri, flag);
return flag;
}
private boolean isFilter(String uri) {
boolean filter = true;
for (String backRegex : jwtFilterBlacklist) {
if (urlMatching(backRegex, uri)) {
return false;
}
}
for (String regex : jwtFilterWhitelist) {
filter = urlMatching(regex, uri);
if (filter) {
return true;
}
}
return filter;
}
protected boolean urlMatching(String regex, String uri) {
return PATH_MATCHER.match(regex, uri);
}
}
添加JwtUtil工具类,如下:
package net.anumbrella.spring.jwt.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.util.Assert;
import java.time.LocalDateTime;
public class JwtUtil {
public static final String AUTH_HEADER_KEY = "Authorization";
public static final String TOKEN_PREFIX = "Bearer ";
/**
* JWT秘钥
*/
public static final String DEFAULT_SECRET = "secret";
public static final String USER_ID = "userId";
/**
* 过期时间,一小时有效期
*/
public static final LocalDateTime EXPIRE_TIME = LocalDateTime.now().plusHours(1);
/**
* 签发JWT
*/
public static String generateToken(String userId, String authInfo) {
return generateToken(userId, authInfo, DEFAULT_SECRET);
}
/**
* 签发JWT
*/
public static String generateToken(String userId, String authInfo, String secret) {
return Jwts.builder()
// 角色权限相关信息
.claim(USER_ID, userId)
.setIssuedAt(new java.util.Date())
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
/**
* 验证JWT
*/
public static Boolean validateToken(String token) {
return validateToken(token, DEFAULT_SECRET);
}
/**
* 验证JWT
*/
public static Boolean validateToken(String token, String secret) {
try {
return getClaimsFromToken(token, secret) != null;
} catch (Exception e) {
throw new IllegalStateException("Invalid Token. " + e.getMessage());
}
}
/**
* 从token中获取用户ID
*/
public static String getUserId(String token) {
return getUserId(token, DEFAULT_SECRET);
}
/**
* 从token中获取用户ID
*/
public static String getUserId(String token, String secret) {
Claims claims = getClaimsFromToken(token, secret);
return claims.get(USER_ID, String.class);
}
/**
* 解析JWT
*/
private static Claims getClaimsFromToken(String token, String secret) {
Claims claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
return claims;
}
}
在这里我两种方式FIlter和自定义HandlerInterceptor都添加了演示,在HandlerInterceptor中拦截特定忽略注解可以忽略到需要认证的接口。自己实际情况结合使用一种即可。
忽略注解JwtIgnore,如下:
package net.anumbrella.spring.jwt.annotation;
import java.lang.annotation.*;
@Documented
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface JwtIgnore {
}
自定义HandlerInterceptor类BaseSecurityInterceptor,如下:
package net.anumbrella.spring.jwt.config;
import net.anumbrella.spring.jwt.annotation.JwtIgnore;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class BaseSecurityInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 忽略带JwtIgnore注解的请求, 不做后续token认证校验
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
JwtIgnore jwtIgnore = handlerMethod.getMethodAnnotation(JwtIgnore.class);
if (jwtIgnore != null) {
return true;
}
}
return true;
}
}
最后进行相关配置注入,如下:
package net.anumbrella.spring.jwt.config;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* @author anumbrella
*/
@Configuration
@RequiredArgsConstructor
public class BaseMvcConfig implements WebMvcConfigurer {
private final JwtProperties jwtProperties;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new BaseSecurityInterceptor());
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(jwtProperties);
}
@Bean
public FilterRegistrationBean jwtFilter() {
FilterRegistrationBean registrationBean = new FilterRegistrationBean();
registrationBean.setFilter(jwtAuthenticationFilter());
return registrationBean;
}
}
到此JWT认证基本工作就完成了。接着新建一个UserRest进行测试,如下:
package net.anumbrella.spring.jwt.rest;
import com.google.gson.Gson;
import net.anumbrella.spring.jwt.model.UserDto;
import net.anumbrella.spring.jwt.util.JwtUtil;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.groups.Default;
@RestController
@RequestMapping(value = "user")
public class UserRest {
@PostMapping("/login")
public ResponseEntity login(@RequestBody @Validated({PostMapping.class, Default.class}) UserDto userDto,
HttpServletResponse response) {
// 获取用户ID
Long userId = 1L;
Gson gson = new Gson();
String token = JwtUtil.generateToken(String.valueOf(userId), gson.toJson(userDto));
// 将token放在响应头
response.setHeader(JwtUtil.AUTH_HEADER_KEY, JwtUtil.TOKEN_PREFIX + token);
return ResponseEntity.ok("login success");
}
@GetMapping("/auth-info")
public ResponseEntity authInfo(HttpServletRequest request) {
String authHeader = request.getHeader(JwtUtil.AUTH_HEADER_KEY);
String token = authHeader.substring(7);
return ResponseEntity.ok(JwtUtil.getUserId(token));
}
}
接着我们之间访问接口可以发现提示认证信息:
然后我们进行登录,接着访问auth-info接口,但是登录接口我们必须先开放不用认证。
我们在header中获取到返回头信息,如下:
最后在auth-info请求头中加入认证Token信息即可。
我们知道在每次请求中都包含请求token,因此每次请求我们都能够从request中获取到保存在JWT中的信息,如果每次需要获取信息,比如userId都要解析很麻烦,有没有好的方法,其实我们可以通过HttpServletRequestWrapper自定义包装一层request的请求。
RequestWrapper,如下:
package net.anumbrella.spring.jwt.config;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.util.Collections;
import java.util.Enumeration;
import static net.anumbrella.spring.jwt.util.JwtUtil.USER_ID;
public class RequestWrapper extends HttpServletRequestWrapper {
private String userId;
RequestWrapper(HttpServletRequest request, String userId) {
super(request);
this.userId = userId;
}
@Override
public Enumeration<String> getHeaders(String name) {
if (USER_ID.equals(name)) {
return Collections.enumeration(Collections.singletonList(userId));
}
return super.getHeaders(name);
}
public String getUserId() {
return userId;
}
}
在认证成功后,包装request返回后面请求链路。如下:
当需要使用,在rest层添加@RequestHeader(value = USER_ID) Long userId
即可。
@GetMapping("/test")
public ResponseEntity test(@RequestHeader(value = USER_ID) Long userId) {
System.err.println(userId);
return ResponseEntity.ok(userId);
}
这里再说明一下JWT和OAuth2的区别,因为老是有同学把这个搞混:
JWT提供了一种用于发布接入令牌(Access Token),并对发布的签名接入令牌进行验证的方法。 令牌(Token)本身包含了一系列声明,应用程序可以根据这些声明限制用户对资源的访问。
另一方面,OAuth2是一种授权框架,提供了一套详细的授权机制(指导)。用户或应用可以通过公开的或私有的设置,授权第三方应用访问特定资源。
简单来说:应用场景不一样
其次关于JWT和Cookie-Session,相关JWT也不是万能的,对于用户退出这种维护JWT就不好实现,需要去维护一个白名单或黑名单。因为无状态JWT一旦被生成,就不会再和服务端有任何瓜葛。一旦服务端中的相关数据更新,无状态JWT中存储的数据由于得不到更新,就变成了过期的数据。
JWT的最佳用途是一次性授权Token,这种场景下的Token的特性如下:
因此JWT不是万能的,是否采用JWT,需要根据业务需求来确定。关于更多讨论也有很多文章,可以参考
jwt 实践以及与 session 对比,JWT与Session的比较,以及国外的讨论token-authentication-vs-cookies。
代码实例:Jwt