JWT认证了解和实践

前面讲解过什么是SSO,OAuth2相关的一系列的知识点,今天讲解一下JWT的相关知识。

一、JWT是什么

JWT的全称为Json Web Token (JWT),是目前最流行的跨域认证解决方案,是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519)。而且该Token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。我们在前面讲解过SSO,知道通过CAS去实现SSO的应用是比较重和庞大的,而通过JWT去实现SSO不失为一个好的方案。

JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该Token也可直接被用于认证,也可被加密。更多详情也可以参考JWT的官网地址

二、与传统的Session认证的区别

既然使用JWT那么我们就要知道它与传统的Session的区别,为啥我们要使用它?带着这个疑问我们来进一步探究。

a、传统Session认证

因为Http协议本身是一种无状态的协议,因此这就意味着如果用户向应用提供了用户名和密码来进行用户认证,那么下一次请求时,用户还要再一次进行用户认证才行,因为根据Http协议,应用并不能知道是哪个用户发出的请求,所以为了让我们的应用能识别是哪个用户发出的请求,我们只能在服务器存储一份用户登录的信息,这份登录信息会在响应时传递给浏览器,保存为Cookie,以便下次请求时发送给我们的应用,这样我们的应用就能识别请求来自哪个用户了,这就是传统的基于Cookie-Session认证。

认证流程:
  1. 用户输入登录信息
  2. 服务器验证登录信息是否正确,如果正确就创建一个Session,并把Session存入数据库(一般是内存中)
  3. 服务器端会向客户端返回带有SessionId的Cookie
  4. 在接下来的请求中,服务器将把SessionId与数据库(一般是内存中)中的相匹配,如果有效则处理该请求
  5. 如果用户登出app,Session会在客户端和服务器端都被销毁
暴露的问题:
  • Session:Cookie+Session这种模式通常是保存在内存中,当客户访问量增加时,服务端就需要存储大量的Session会话,而且服务从单服务到多服务会面临的Session共享问题,随着用户量的增多,开销就会越大。
  • CSRF: 因为是基于Cookie来进行用户识别的, Cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。
  • 扩展性:对于集群扩展需要通过采用缓存一致性技术来保证可以共享,或者采用第三方缓存来保存Session来解决Session在内存中的问题。
b、JWT认证

基于Token的身份验证是无状态的,服务器不需要记录哪些用户已经登录或者哪些JWT已经处理。每个发送到服务器的请求都会带上一个Token,服务器利用这个Token检查确认请求的真实性。

Token通常以Bearer { JWT }的形式附加在已验证的请求头中,但是也可以用POST请求体或者问句参数进行传递。

认证流程:
  1. 用户输入登录信息
  2. 服务器验证登录信息,如果正确就返回一个已签名的Token
  3. 这个Token存储在客户端,最常见的是存储在localStorage中,但是也可以存在Session Storage和Cookie中
  4. 之后向服务器发送的请求都会带上这个Token
  5. 服务器解码JWT,如果Token是有效的则处理这个请求
  6. 如果用户退出登录,Token会在客户端销毁,这一步与服务器无关

相对于传统的Session认证方式,JWT天生支持无状态,同时它也更更安全,通用性JSON,扩展强,支持跨域访问等。

三、JWT的组成

JWT是由三段信息构成的,将这三段信息文本用.链接一起就构成了JWT字符串。如下所示:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.UQmqAUhUrpDVV2ST7mZKyLTomVfg7sYkEjmdDI5XF8Q

第一部分我们称它为头部(header),第二部分我们称其为载荷(payload,类似于飞机上承载的物品),第三部分是签证(signature)。

a、header(头部)

JWT的头部承载两部分信息:

  • 声明类型,这里是JWT
  • 声明加密的算法,通常直接使用 HMAC SHA256

完整的头部就像下面这样的JSON:

{
  'typ': 'JWT',
  'alg': 'HS256'
}

然后将头部进行base64加密(该加密是可以对称解密的),构成了第一部分

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
b、payload(载荷)

payload是JWT的组成部分的第二块,载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分:

  1. 标准中注册的声明
  2. 公共的声明
  3. 私有的声明
(1)、标准中注册的声明 (建议但不强制使用) :
  • iss: JWT签发者
  • sub:JWT所面向的用户
  • aud:接收JWT的一方
  • exp:JWT的过期时间,这个过期时间必须要大于签发时间
  • nbf:定义在什么时间之前,该JWT都是不可用的
  • iat:JWT的签发时间
  • jti:JWT的唯一身份标识,主要用来作为一次性Token,从而回避重放攻击
(2)、公共的声明 :

公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息。但不建议添加敏感信息,因为该部分在客户端可解密。

(3)、私有的声明 :

私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。

比如这里我们定义一个payload:

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

然后将其进行base64加密,得到JWT的第二部分:

eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
c、signature(签名)

JWT的第三部分是一个签证信息,这个签证信息由三部分组成:

  1. header (base64后的)
  2. payload (base64后的)
  3. secret

这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符串(头部在前),然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了JWT的第三部分。

UQmqAUhUrpDVV2ST7mZKyLTomVfg7sYkEjmdDI5XF8Q

密钥secret是保存在服务端的,服务端会根据这个密钥进行生成token和验证,所以需要保护好。

d、签名的目的

最后一步签名的过程,实际上是对头部以及载荷内容进行签名。一般而言,加密算法对于不同的输入产生的输出总是不一样的。对于两个不同的输入,产生同样的输出的概率极其地小。所以,我们就把“不一样的输入产生不一样的输出”当做必然事件来看待吧。

所以,如果有人对头部以及载荷的内容解码之后进行修改,再进行编码的话,那么新的头部和载荷的签名和之前的签名就将是不一样的。而且,如果不知道服务器加密的时候用的密钥的话,得出来的签名也一定会是不一样的。

f、实际应用

一般是在请求头里加入Authorization,并加上Bearer标注:

fetch('api/user/1', {
  headers: {
    'Authorization': 'Bearer ' + token
  }
})

四、在Spring中使用JWT

这里主要讲解一下在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

然后我们进行登录,接着访问auth-info接口,但是登录接口我们必须先开放不用认证。

login

接着我们先进行登录,如下:
login success

我们在header中获取到返回头信息,如下:

response 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返回后面请求链路。如下:

userId

当需要使用,在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是一种认证协议

JWT提供了一种用于发布接入令牌(Access Token),并对发布的签名接入令牌进行验证的方法。 令牌(Token)本身包含了一系列声明,应用程序可以根据这些声明限制用户对资源的访问。

OAuth2是一种授权框架

另一方面,OAuth2是一种授权框架,提供了一套详细的授权机制(指导)。用户或应用可以通过公开的或私有的设置,授权第三方应用访问特定资源。

简单来说:应用场景不一样

  1. OAuth2用在使用第三方账号登录的情况(比如使用weibo,qq,github登录某个app)
  2. JWT是用在前后端分离, 需要简单的对后台API进行保护时使用.(前后端分离无session,频繁传用户密码不安全)

其次关于JWT和Cookie-Session,相关JWT也不是万能的,对于用户退出这种维护JWT就不好实现,需要去维护一个白名单或黑名单。因为无状态JWT一旦被生成,就不会再和服务端有任何瓜葛。一旦服务端中的相关数据更新,无状态JWT中存储的数据由于得不到更新,就变成了过期的数据。

JWT的最佳用途是一次性授权Token,这种场景下的Token的特性如下:

  • 有效期短
  • 只希望被使用一次

因此JWT不是万能的,是否采用JWT,需要根据业务需求来确定。关于更多讨论也有很多文章,可以参考
jwt 实践以及与 session 对比JWT与Session的比较,以及国外的讨论token-authentication-vs-cookies

代码实例:Jwt

参考

  • 4
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值