JWT

1、什么是JWT

本教程参考:B站视频:“JWT认证原理、流程整合springboot实战应用,前后端分离认证的解决方案!”

网址:https://www.bilibili.com/video/BV1i54y1m7cP?p=3

官网:https://jwt.io/introduction/
JsonWebToken(JWT)是一个开放的标准(rfc7519),它定义了一种紧凑的、自包含的方式,用于在各方之间以Json对象安全的传输信息,此信息可以验证和信任,因为它是数字签名的。jwt可以使用秘密(使用HMAC算法)或者使用RSA或ECDSA的公钥/私钥对进行签名。

​ 通俗来说,通过JSON方式作为WEB应用中的令牌,用于在各方之间用JSON对象安全的传输信息。传输过程中,还能进行加密,签名等操作。

2、JWT能做什么

  • 授权:这是使用JWT的最常见方案,一旦用户登录,每个后续请求将包括JWT,从而允许用户访问该令牌允许的路由,服务和资源。单点登录是当今广泛使用JWT的一项功能,因为它的开销很小并且可以在不同的域中轻松使用
  • 信息交换:JWT是各方之间传输信息保证安全的好方法,因为它可以进行数据加密,签名等操作。此外,由于签名是使用标头和有效负载计算的,因此你还可以验证签名是否被篡改。

3、为什么是JWT

基于传统的Seesion认证

1、认证方式

​ 我们知道,http协议本身是一种无状态的协议,这意味着如果用户向我们的应用提供了用户名和密码进行用户认证,那么下一次请求,用户还需要重新提交一次用户名与密码,因为根据http协议,我们并不知道是哪个用户发出的请求,为了让我们记住用户的登录信息且能识别出是哪一个用户,我们需要在服务器存放一份用户登录的信息。这份信息会在响应时传递给浏览器,告诉它保存为cookie,以便下一次请求时浏览器将cookie携带上,服务器再通过cookie识别出请求来自哪个用户。这便是传统的Seesion认证。

2、认证流程

3、暴露问题

(1)每个用户经过应用认证之后,我们的应用都要在服务器做一次记录,以便下次方便识别用户。通常Session信息存放在内存中,随着用户访问量越来越大,开销也越来越大。

(2)存在内存中的Session信息,只能被内存所在的服务器读取。这样在分布式的应用上,相应的限制了负载均衡的能力,也限制了应用的扩展能力。

(3)cookie有被截获的风险,服务器有可能会遭受到他人请求伪造的攻击。

(4)在前后端系统中,会更加的痛苦。

​ 前后端系统解耦的同时,增加了部署的复杂性。通常用户的一次请求就会被转发多次。如果采用传统Session方式,除了会出现前面三点提到的问题以外,还有就是SessionID就是一个特征值,表达的信息不够丰富,不容易扩展,同时若果后端应用的事多节点部署,还必须实现Session共享机制。

基于JWT认证

1.认证流程

  • 前端通过post请求发送用户名与信息,建议通过SSL加密的传输(https协议),以免敏感信息被嗅探。
  • 后端对用户名密码验证成功后,将用户id等其他信息作为JWT Payload(负载),将其头部分别进行Base64编码拼接后签名,形成一个JWT,形成的JWT就是一个形同lll.zzz.xxx的字符串。
  • 后端将JWT字符串作为登录成功后的结果返回前端,前端可以将结果保存在localStorage或者SessionStorage上,退出登录时前端删除保存的JWT即可。
  • 前端在每次请求时,将JWT放入HTTP Header中的Authorization位。(解决XSS和XSRF问题)
  • 后端检查是否存在,如存在验证JWT有效性,例如检查签名是否正确,token是否过期,检查token接收方是否是自己(可选)
  • 验证通过后后端使用JWT中包含的用户信息进行其他逻辑操作,返回相应结果

2、jwt优势

  • 简洁(Compact):通过UPL,POST参数或者在HTTP header发送,因为数据量小,传输速度也很快。
  • 自包含(self-contained):负载中包含了所有用户需要的信息,避免了多次查询数据库
  • 因为token以加密的形式保存在客户端的,所以JWT是跨语言的,理论上各种web形式都支持
  • 用计算换内存,jwt不像传统Session需要在服务器中保存客户信息,适合分布式。

3、劣势

  • 不能主动吊销令牌,只能等令牌过期
  • 令牌长度与信息量正相关,携带信息越多,传输开销越大

JWT的组成

JWT是Auth0提出的通过对JSON进行加密签名来实现授权验证的方案,编码之后的JWT看起来是这样的一串字符:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.
TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

通用格式:XXX.XXX.XXX  对应部分:Header.Playload.Signature

(1)头部(Hader)

​ jwt头部承载两部分信息:

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

完整的头部信息如下:

//一段json格式
{
    'typ' : 'JWT',
    'alg' : 'HS256' 
}

头部信息最终会同伙base64加密(该加密可对称解密),构成第一部分

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.

(2)载荷(payload)

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

  • 标准中注册的声明
  • 公共的声明
  • 私有的声明

标准中注册的声明(建议但不强制使用):

  • iss: 该JWT的签发者,一般是服务器,是否使用是可选的;
  • iat(issued at): 在什么时候签发的(UNIX时间),是否使用是可选的;
  • exp(expires): 什么时候过期,这里是一个Unix时间戳,这个过期时间必须要大于签发时间,是否使用是可选的;
  • aud: 接收该JWT的一方,是否使用是可选的;
  • sub: 该JWT所面向的用户,userid,是否使用是可选的;
  • nbf:定义在什么时间之前,该JWT都是不可用的;
  • jti:jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击;

公共的声明:

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

私有的声明:

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

定义一个playload:


// 包括需要传递的用户信息;
{ "iss": "Online JWT Builder", 
  "iat": 1416797419, 
  "exp": 1448333419, 
  "aud": "www.gusibi.com", 
  "sub": "uid", 
  "nickname": "goodspeed", 
  "username": "goodspeed", 
  "scopes": [ "admin", "user" ] 
}
 

将上面的JSON对象进行Base64编码可以得到JWT的字符串,也就是上面三段式字符串的第二段,我们将它称作JWT的 Payload

eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE0MTY3OTc0MTksImV4cCI6MTQ0ODMzMzQxOSwiYXVkIjoid3d3Lmd1c2liaS5jb20iLCJzdWIiOiIwMTIzNDU2Nzg5Iiwibmlja25hbWUiOiJnb29kc3BlZWQiLCJ1c2VybmFtZSI6Imdvb2RzcGVlZCIsInNjb3BlcyI6WyJhZG1pbiIsInVzZXIiXX0


信息会暴露:由于这里用的是可逆的base64 编码,所以第二部分的数据实际上是明文的。我们应该避免在这里存放不能公开的隐私信息。

(3)签名(signature)


// 根据alg算法与私有秘钥进行加密得到的签名字串;
// 这一段是最重要的敏感信息,只能在服务端解密;
HMACSHA256(  
    base64UrlEncode(header) + "." +
    base64UrlEncode(payload),
    SECREATE_KEY
)

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

  • header (base64后的)
  • payload (base64后的)
  • secret

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

// javascript
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);
 
var signature = HMACSHA256(encodedString, 'secret'); 

将这三部分用.连接成一个完整的字符串,构成了最终的jwt:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE0MTY3OTc0MTksImV4cCI6MTQ0ODMzMzQxOSwiYXVkIjoid3d3Lmd1c2liaS5jb20iLCJzdWIiOiIwMTIzNDU2Nzg5Iiwibmlja25hbWUiOiJnb29kc3BlZWQiLCJ1c2VybmFtZSI6Imdvb2RzcGVlZCIsInNjb3BlcyI6WyJhZG1pbiIsInVzZXIiXX0.pq5IDv-yaktw6XEa5GEv07SzS9ehe6AcVSdTj0Ini4o

注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。

签名的目的:签名实际上是对头部以及载荷内容进行签名。所以,如果有人对头部以及载荷的内容解码之后进行修改,再进行编码的话,那么新的头部和载荷的签名和之前的签名就将是不一样的。而且,如果不知道服务器加密的密钥的话,得出来的签名也一定会是不一样的。这样就能保证token不会被篡改。

安全相关

  • 不应该在jwt的payload部分存放敏感信息,因为该部分是客户端可解密的部分。
  • 保护好secret私钥,该私钥非常重要。
  • 如果可以,请使用https协议

4、JWT的第一个程序

1、引入依赖

<!--引入JWT-->
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.4.0</version>
        </dependency>

2、生成token

public static void main(String[] args) {
        //生成令牌
        HashMap<String,Object> map=new HashMap<>();
        Calendar instance = Calendar.getInstance();
        instance.add(Calendar.SECOND,20);

       String token = JWT.create()
                .withHeader(map)//头部
                .withClaim("userid",21)//payload
                .withClaim("username","废熊")
                .withExpiresAt(instance.getTime())//令牌过期时间,使用Calendar生成赋值
                .sign(Algorithm.HMAC256("!@#123qwe"));//签名

        System.out.println(token);
    }

结果:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MTc1ODQ5OTksInVzZXJpZCI6MjEsInVzZXJuYW1lIjoi5bqf54aKIn0.IcYJQ6I1bFfwXdBaJnOFUwFsCoFYVvyB02IRhmF4S1k

3、认证token

/创建验证对象
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256("token!Q2W#E$RW")).build();
//获得解码后的对象
DecodedJWT verify = jwtVerifier.verify(
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MDM4NTI0ODUsInVzZXJJZCI6NywidXNlcm5hbWUiOiJsb3VycSJ9.8DFnHQLA1XLlGxJjdwPWapW55OXQzELyncRQUPrapco");
 
System.out.println(verify.getClaim("userId").asInt());
System.out.println(verify.getClaim("username").asString());
System.out.println("过期时间:"+verify.getExpiresAt());

4、常见异常信息

  • SignatureVerificationException:签名不一致异常
  • TokenExpiredException:令牌过期异常
  • AlgorithmMismatchException:算法不匹配异常
  • InvalidClainException:失效的payload异常

5、jwt工具类的封装

为了方便后续开发,将token的两个方法封装起来。第一个方法是将用户传入的信息生成token,第二个方法是验证token。

在写方法之前,首先要将密钥定义为静态变量 SIGN,因为它始终不变,这样写方便方法中使用。

private static final String SIGN = "!Q2W#E$RW";

工具类:

  /**
     * 生成token header.payload.sign
     */
    public static String getToken(Map<String,String> map){
        Calendar instance = Calendar.getInstance();
        instance.add(Calendar.DATE, 7); //默认7天过期
        //创建jwt builder
        JWTCreator.Builder builder = JWT.create();
        //payload
        map.forEach((k,v)->{
            builder.withClaim(k,v);
        });
 
        String token = builder.withExpiresAt(instance.getTime())//指定令牌的过期时间
                .sign(Algorithm.HMAC256(SIGN));//sign
        return token;

    }



 /**
     * 验证token合法性
     * 验证成功:返回解码后的对象
     * 验证失败:抛出异常
     */
    public static DecodedJWT verify(String token){
        return JWT.require(Algorithm.HMAC256(SIGN)).build().verify(token);
    }
 




6、Springboot整合JWT

需要提供两个接口,用户认证接口和访问前检验token接口。

流程:

  • 用户传入username和password(以“zhangsan" "123456"为例),拿着这两个信息去查数据库作认证。若认证通过,基于想要的用户信息生成一个token,然后把token响应给用户。
  • 日后用户在请求其他接口的时候,用户都要携带着token请求。若携带的token验证通过,则服务器允许访问;若验证不通过,则服务器拒绝访问。

1、引入依赖,同上,省略

2、建立数据库以及user表,常如一行自定义测试数据。

3、在配置文件上配置mysql和mybatis

4、建立实体类

UserAdmin.java 使用了lombok插件

@Data
public class UserAdmin {
    private String id;
    private String name;
    private String password;
    }

5、DAO接口和mapper.xml


@Mapper
public interface UserAdminMapper{
    UserAdmin login(UserAdmin userAdmin);

mapper文件

<mapper namespace="com.xxx.gamejump.mapper.UserAdminMapper">
    <select id="login" parameterType="UserAdmin" resultType="UserAdmin">
        select * from g_admin where name =#{name} and password =#{password}
    </select>
</mapper>

6、Service接口以及实现类


@Service
public interface UserAdminService {
    /*
        管理员登录接口
     */
    UserAdmin login(UserAdmin userAdmin);


@Service
@Transactional
public class UserAdminServiceImpl implements UserAdminService{
 
    @Autowired
    private UserAdminMapper userAdminMapper;
 
    @Override
    @Transactional(propagation = Propagation.SUPPORTS)
    public UserAdmin login(UserAdmin userAdmin) {
        //根据接收的用户名和密码查询数据库
        UserAdmin admin = userAdminMapper.login(userAdmin);
        if(admin != null){
            return admin;
        }
        throw new RuntimeException("登陆失败--");
    }
}

7、controller类

login方法:验证用户名密码,生成token令牌


@RestController
@RequestMapping("/admin")
@CrossOrigin(origins = "*")
public class UserAdminController {
    private Logger logger = LoggerFactory.getLogger(UserAdminController.class);
 
    @Autowired
    private UserAdminService userAdminService;
 
    @GetMapping("/login")
    public Map<String,Object> login(UserAdmin userAdmin){
        logger.info("用户名:[{}]",userAdmin.getName());
        logger.info("密码:[{}]",userAdmin.getPassword());
        Map<String,Object> map = new HashMap<>();
        try{
            UserAdmin useradminDB = userAdminService.login(userAdmin);
            Map<String,String> payload = new HashMap<>();
            payload.put("id",useradminDB.getId());
            payload.put("name",useradminDB.getName());
            //生成JWT令牌,项目中引用前面提到的JWTutil工具类
            String token = JWTUtils.getToken(payload);
            map.put("state",true);
            map.put("msg","认证成功");
            map.put("token",token);//响应token
        }catch(Exception e){
            map.put("state",false);
            map.put("msg",e.getMessage());
        }
 
        return map;
    }

首先运行程序,然后用Postman工具测试login方法。

测试1:认证成功示例

GET方式访问:http://localhost:8089/admin/login?name=lrq&password=12345

传参:

name:lrq

password:12345

数据库存在该数据,所以是成功的

8、验证类

只有第一次访问需要从数据库获取信息,在token失效前,用户后面的访问都会携带token,我们需要对其进行验证。

 @PostMapping("/test")
    public Map<String,Object> test(String token) {
        Map<String, Object> map = new HashMap<>();
        logger.info("当前token为:[{}]", token);
        //System.out.println("当前token为");
        try {
            DecodedJWT verify = JWTUtils.verify(token); //验证令牌
            map.put("state",true);
            map.put("msg","请求成功!");
            return map;
        } catch (SignatureVerificationException e) {
            e.printStackTrace();
            map.put("msg","无效签名!");
        } catch (TokenExpiredException e) {
            e.printStackTrace();
            map.put("msg","token过期!");
        } catch (AlgorithmMismatchException e) {
            e.printStackTrace();
            map.put("msg","算法不一致!");
        } catch (Exception e) {
            e.printStackTrace();
            map.put("msg","token无效!");
        }
        map.put("state",false);
        return map;
    }

注意:官方建议token隐藏在header请求头中进行传参

8、增加拦截器

问题:

假设应该受保护的接口有10个,那这10个接口除了自己本身的参数之外每次还需要额外传token数据作为参数,每个方法都需要验证token。这样就造成了大量的代码冗余,且不够灵活。

优化/解决方法:

若是JavaWeb项目,可以将jwt验证放在拦截器里。

若是springcloud分布式项目,可以将jwt验证放在网关里

8.1 拦截器

public class JWTInterceptor implements HandlerInterceptor{
 
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        Map<String,Object> map = new HashMap<>();
        //获取请求头中的令牌
        String token = request.getHeader("token");
        try{
            JWTUtils.verify(token);
            return true;//放行请求
        } catch (SignatureVerificationException e){
            e.printStackTrace();
            map.put("msg","无效签名!");
        } catch (TokenExpiredException e) {
            e.printStackTrace();
            map.put("msg","token过期!");
        } catch (AlgorithmMismatchException e) {
            e.printStackTrace();
            map.put("msg","算法不一致!");
        } catch (Exception e) {
            e.printStackTrace();
            map.put("msg","token无效!");
        }
        map.put("state",false);//设置状态
        //将map 转化为json jackson
        String json = new ObjectMapper().writeValueAsString(map);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().println(json);
        return false;
    }
}
  • 集成HandleInterceptor接口
  • 重写preHandle预处理方法
  • 官方不建议Jwt以参数传入,而是放入请求头里传入
  • 若验证成功,不需要返回携带成功信息的map,返回true即可方形。但是失败,需要返回false和将map以json的方式返回前端。

8.2、拦截器配置类


@Configuration
public class InterceptorConfig implements WebMvcConfigurer{
 
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new JWTInterceptor())
                .addPathPatterns("/admin/test")         //其他接口token验证
                .excludePathPatterns("/admin/login");   //所有管理员用户都放行
    }

拦截路径:所有接口路径,需要排除用户登录验证用户名密码的方法路径,这个接口是用来验证用户并生成token的,不能拦截。因为是demo,所以这里写的具体一点,拦截test方法,不拦截login方法。

8.3、Controller类

  @PostMapping("/test")
    public Map<String,Object> test() {
        Map<String, Object> map = new HashMap<>();
        //处理自己业务逻辑
        map.put("state",true);
        map.put("msg","请求成功!");
        return map;

posted @ 2021-04-12 18:05  废熊  阅读(303)  评论(0)    收藏  举报