SpringBoot+JWT+SpringSecurity+MP实现RestFul接口的鉴权

前言

写本篇文章的目的是为了帮助自己回忆复习也是为了帮助需要使用Java做后端接口的童鞋们, 方便自己也方便他人吧!

在我之前的一篇文章里面介绍的是使用JWT+SSM框架实现接口的鉴权, 在这里我将介绍一下SpringBoot+Security的鉴权方式

再指正一下, 这里的 MP 指的是, emmmm不是你们想得那样

Mybatis-plus 简称 MP

本篇文章着重介绍SpringSecurity的实现方式, MP等等自己找一下教程看看吧, 网上资料还是很多的

最后提一句, 本篇文章所写的代码仅适用于我的毕设项目, 不代表可以用于线上项目, 如果需要可能需要进一步优化, 蟹蟹!

什么是JWT

简介

JWT是Json Web Token的缩写简称, JWT是JSON格式的Web密钥

JSON Web令牌(JWT)是一个开放标准(RFC 7519),它定义了一种紧凑且自包含的方式,用于在各方之间安全地将信息作为JSON对象传输。由于此信息是经过数字签名的,因此可以被验证和信任。可以使用秘密(使用HMAC算法)或使用RSA或ECDSA的公用/专用密钥对对JWT进行签名

尽管可以对JWT进行加密以在各方之间提供保密性,但我们将重点关注已签名的令牌。签名的令牌可以验证其中包含的声明的完整性,而加密的令牌则将这些声明隐藏在其他方的面前。当使用公钥/私钥对对令牌进行签名时,签名还证明只有持有私钥的一方才是对其进行签名的一方。

以上是机翻官网的文档

What is JSON Web Token?

JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDSA.
Although JWTs can be encrypted to also provide secrecy between parties, we will focus on signed tokens. Signed tokens can verify the integrity of the claims contained within it, while encrypted tokens hide those claims from other parties. When tokens are signed using public/private key pairs, the signature also certifies that only the party holding the private key is the one that signed it.

我们该什么时候使用它

这里先大概说一下两种场景:

这个是JSON WEB令牌(JWT)中最常见的方案, 一旦用户登录, 每个后续请求都将包括JWT, 从而判断该用户是否有权限访问相对应的路由, 服务和资源

单点登录就是当下使用最广泛的JWT场景, 因为它对服务器的开销极小, 并且能够在不同中快捷的使用

JSON Web令牌是在各方之间安全地传输信息的好方法。因为可以对JWT进行签名(例如,使用公钥/私钥对),所以您可以确定发件人是他们所说的人

此外,由于签名是使用标头和有效负载计算的,因此您还可以验证内容是否遭到篡改

JWT如何工作(How To Work?)

在身份验证鉴权中, 当用户进行成功登陆的时候, 服务器将返回一个JSON WEB 令牌(JWT), 这一串JWT格式的编码便是我们的凭据, 因此, 我们应该格外的小心, 并且进行一些安全保护措施, 通常, 权限等级越高的用户组别的令牌保留时间应该越短

关于JWT的一些基础详见我的另外一篇文章

由于缺乏安全性, 你也不应该将敏感的会话数据存储在浏览器中

每当用户想要访问受保护的路由或资源时,用户代理通常应使用承载模式在授权标头中发送JWT 。标头的内容应如下所示:

Authorization: Bearer <token>

在某些情况下,这可以是无状态授权机制。服务器的受保护路由将在Authorization标头中检查有效的JWT ,如果存在,则将允许用户访问受保护的资源。如果JWT包含必要的数据,则可以减少查询数据库中某些操作的需求,尽管这种情况并非总是如此。

如果令牌是在Authorization标头中发送的,则跨域资源共享(CORS)不会成为问题,因为它不使用cookie。

当然, 如果你的项目已经配置了跨域, 你也可以将token放在参数中, 每次请求带上token的参数, 那么也能够成功进行鉴权操作

格式

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
header{
    "alg": "HS256",
    "typ": "JWT"
}
playload{
    "sub": "1234567890",
    "name": "John Doe",
    "iat": 1516239022
}
VERIFY SIGNATURE{
    base64UrlEncode(header) + "." +
    base64UrlEncode(payload),
    your-256-bit-secret
}

如有更多疑问, 请查看官方文档的介绍

SpringSecurity

这个是Spring提供的一套全面的安全框架, 在我的另一篇文章也有简单的介绍

SpringBoot实现

引入依赖

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybatisPlus.version}</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>

这里需要注意的是,如果你想要使用最新版本的JJWT,请查看官方文档的介绍,则需要向如下引用JJWT


<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.0</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.0</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
     <!-- or jjwt-gson if Gson is preferred -->
     <!-- 在这里你也可以引入jjwt-gson -->
     <!-- 意思是你选择jjwt所进行的序列化库为Gson -->
    <version>0.11.0</version>
    <scope>runtime</scope>
</dependency>

一些准备

在常量池中加入常量

package com.eendtech.witkey.constants;

import lombok.Getter;

/**
 * @ author Seale
 * @ Description:  项目基本常量池
 * @ QQ:1801157108
 * @ Date 2020/2/1 19:22
 */
public class BaseConstant {

    @Getter
    public enum BaseConfig{
        DEFAULT_SIZE(10),
        DEFAULT_PAGE(1);

        private Integer val;
        BaseConfig (Integer val){
            this.val = val;
        }
    }

    @Getter
    public enum User {
        GROUP_TYPE_USER("用户组",0),
        GROUP_TYPE_ADMIN("管理组",1);
        private Integer val ;
        private String name ;
        User(String name , Integer val){
            this.name = name ;
            this.val = val;
        }
    }

    @Getter
    public enum Oauth{
        TOKEN_PREFIX("Bearer "),
        HEADER_PARAM("Authorization");
        private String val;
        Oauth(String val){
            this.val = val;
        }
    }

    @Getter
    public  enum Returns {
        SUCCESS(200, "成功"),
        TIMEOUT(201, "请求超时,或许是服务器原因"),
        FAIL(202, "请求失败"),
        PARAM_ERRO(203,"参数传输错误"),
        NOTAUTH(300, "无权限请求"),
        TOKEN_PARSE_FAILED(301,"Token校验失败"),
        TOKEN_Expired(302,"Token令牌过期"),
        UNKNOWN_ERROER(800,"未知错误");

        private Integer code;
        private String status;

        private Returns(Integer code, String status) {
            this.code = code;
            this.status = status;
        }
    }

    @Getter
    public enum ReturnMessage{
        PARAM_NOT_NULL("必要参数不能为空"),
        USER_OR_PWD_ERR("帐号或密码错误,登录失败"),
        NOT_FOUND_TOKEN("没有携带Token,或者是Token校验失败"),
        NOT_OAUTH_REQUEST("没有权限访问");

        private  String val ;

        ReturnMessage (String val){
            this.val = val;
        }
    }
    .....

}

以上是本篇文章所需要的常量

新增用户注册的控制器

package com.eendtech.witkey.controller;

import com.eendtech.witkey.constants.BaseConstant;
import com.eendtech.witkey.model.User;
import com.eendtech.witkey.service.UserService;
import com.eendtech.witkey.utils.BaseUtils;
import com.eendtech.witkey.utils.ResultUtils;
import com.eendtech.witkey.utils.ReturnsKit;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.*;

/**
 * @ author Seale
 * @ Description:
 * @ QQ:1801157108
 * @ Date 2019/11/30 21:49
 */
@RestController
@RequestMapping("/api/oauth")
public class LoginController {
    @Autowired
    UserService userService;
    @Autowired
    private BCryptPasswordEncoder bCryptPasswordEncoder;

    @PostMapping("/register")
    public ReturnsKit register(@RequestBody User user) {
        if (BaseUtils.verifyParam(user.getUsername(), user.getPassword())) {
            user.setPassword(bCryptPasswordEncoder.encode(user.getPassword()));
            String msg = userService.addUser(user);
            if (msg.equals("用户创建成功"))
                return ResultUtils.success(msg);
            else
                return ResultUtils.erro(BaseConstant.Returns.FAIL, msg);
        } else
            return ResultUtils.erro(BaseConstant.Returns.PARAM_ERRO, BaseConstant.ReturnMessage.PARAM_NOT_NULL);
    }

    @GetMapping("/check/{username}")
    public ReturnsKit check(@PathVariable String username) {
        if (BaseUtils.verifyParam(username)) {
            String msg = userService.chekUserName(username);
            return ResultUtils.success(msg);

        }else return ResultUtils.erro(BaseConstant.Returns.PARAM_ERRO, BaseConstant.ReturnMessage.PARAM_NOT_NULL);
    }

}

这里需要注意的是,SpringSecurity中默认要求是需要进行密码加密的,如果你的密码不进行加密会报异常

JWT工具类的封装

上一篇SSM框架集成JWT鉴权的文章,对JWT工具类的封装并不是很好,归结一点,当时比现在还要菜一点

package com.eendtech.witkey.utils;

import com.eendtech.witkey.Exception.TokenException;
import com.eendtech.witkey.constants.BaseConstant;
import io.jsonwebtoken.*;
import io.jsonwebtoken.impl.Base64UrlCodec;
import io.jsonwebtoken.impl.DefaultClaims;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Date;

/**
 * @ author Seale
 * @ Description: JJWT签发工具类
 * @ QQ:1801157108
 * @ Date 2020/2/7 19:55
 */
public class JwtUtils {

    private static final String SECRET = "EENDTECHHCETDNEE520131....4!@#$%Y^ASDHUIsadasd#@#!@#156489d1svxcv4cx123a894!!@@@@###@{}{}{gasyudgas}JAIOASJD";
    private static final String ISS = "EENDTECH";//签发者

    //默认过期时间是1天
    private static final long EXPIRATION = 3600 * 24 * 1000L;

    //生成TOKEN

    /***
     * 函数名: GenToken
     * 函数说明: 生成TOKEN令牌
     * 创建时间: 2020/2/7 20:01
     * @Param userName: 用户名
     * @Param group: 当前用户组
     * @Param isForever: 是否为永久令牌
     * @param uid: 用户的id
     * @return: java.lang.String
     */
    public static String GenToken(String userName, Integer uid, String group, boolean isForever) {
        Claims claims = new DefaultClaims();
        claims.setId(uid.toString())
                .setIssuer(ISS) //签发者
                .setIssuedAt(BaseUtils.getTimeNow()) // 签发时间
                .setAudience(userName) //接收方
                .put("group", group);
        claims.put("user", userName);
        JwtBuilder jwt = Jwts.builder()
                .signWith(SignatureAlgorithm.HS512, generalKey())
                .setId(uid.toString())
                .setClaims(claims);
        System.err.print("当前生成的signKey:" + "\t");
        for (byte i : generalKey().getEncoded()) System.err.print(i);
        if (!isForever) {
            long exp = System.currentTimeMillis() + EXPIRATION;
            jwt.setExpiration(new Date(exp)); // 设置过期时间为一天后
        }
        String r = jwt.compact();
        System.out.println("\n原始Token\t" + r);
        // 对结果进行Base64Url编码后发送
        r = Base64UrlCodec.BASE64URL.encode(r);

        return r; //返回生成的令牌
    }

    //从令牌中获取用户名
    public static String getUserName(String token) {
        return validateJwt(token).get("user").toString();
    }

    //从令牌中获取uid
    public static String getUid(String token) {
        return validateJwt(token).getId();
    }

    //从令牌中获取用户组
    public static String getGroup(String token) {
        return validateJwt(token).get("group").toString();
    }

    /***
     * 函数名: validateJwt
     * 函数说明: 验证当前的令牌
     * 创建时间: 2020/2/7 20:56
     * @Param token:
     * @return: com.eendtech.witkey.utils.ReturnsKit
     */
    public static Claims validateJwt(String token) {
        Claims claims = null;
        try {
            claims = parseToken(token);
        }catch (ExpiredJwtException e) {
            System.err.println("当前Token过期了!");
        }
        catch (SignatureException e1) {
            System.err.println("当前Token检验失败,不符合加密规则");
            throw new TokenException(BaseConstant.Returns.TOKEN_PARSE_FAILED,"当前Token检验失败,不符合加密规则");
        }
        if (claims == null){
            throw new TokenException(BaseConstant.Returns.TOKEN_PARSE_FAILED,"token校验失败");
        }
        return claims;
    }

    // 解析Token playload
    public static Claims parseToken(String token) {
        //对token进行解码
        token = Base64UrlCodec.BASE64URL.decodeToString(token);
        return Jwts.parser()
                .setSigningKey(generalKey())
                .parseClaimsJws(token)
                .getBody();

    }

    public static SecretKey generalKey() {
        byte[] encodeDkey = SECRET.getBytes();
        return new SecretKeySpec(encodeDkey, 0, SECRET.length(), "AES");
    }
}

上面留有一些测试打印代码,我就没删除了!

在这里你会发现我们对JWT的Token进行验证的时候捕获了JWT库中抛出的一些基本异常,但是为什么我又抛出了一个TokenException异常呢?

这一点容我后面详谈

我们先介绍一下这个工具类的大体作用,首先,是这个核心方法GenToken,他的作用是生成一个token令牌,在这个工具类中我们对一些必要参数进行了初始化,比如SECRETISS -> 密钥和签发者,同时也设置了默认的过期时间为1天,当然,这些常量你也可以将它们移动到常量池中

我们通过Jwts.builder().**.compact()来构建一个String类型的Token令牌

但是,指的注意的是,在这里生成的Token令牌的格式为标准的JWT格式

也就是说我们得到的String字符串为

abcd.efgasdasd.asdasd

你或许会有疑问了,这有什么不对吗?确实,如果说你将Token放在Header中来进行鉴权,那么确实没有问题,但是有些场景下,我们需要将这个token放在我们的URL后面,也就如下所示

https://imsle.com/test?id=xx&page=1&token=

显然我们不可能那么做,所以在这里我们需要进行一次Base64URL的转码操作,当我们从request中读取到token的时候又需要进行对token的Base64URL的解码操作

        //对token进行解码
        token = Base64UrlCodec.BASE64URL.decodeToString(token);

        // 对结果进行Base64Url编码后发送
        String r = jwt.compact();
        r = Base64UrlCodec.BASE64URL.encode(r);

这样一来,我们得到的token就类似于这样了

ZXlKaGJHY2lPaUpJVXpVeE1pSjkuZXlKcWRHa2lPaUkzSWl3aWFYTnpJam9pUlVWT1JGUkZRMGdpTENKcFlYUWlPakUxT0RFeE5EWTBOVFVzSW1GMVpDSTZJblJsYzNReE1URXhJaXdpWjNKdmRYQWlPaUpTVDB4RlgxVlRSVklpTENKMWMyVnlJam9pZEdWemRERXhNVEVpZlEud2FKaGlaUE1PZG9lWXA4MGk2aWFOTTVERjlGQVY5Zzh6V2sxUnBmOHZpdzRmWWlacG10RWE2RzgtLVVQNlNmTWUtRmFySm91Sjk4MFFYRFpBOVRtSGc

其中的.已经被转换掉了,这个时候我们就可以放心的在url后接token参数了

封装其他基本工具类

返回工具类

package com.eendtech.witkey.utils;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.Accessors;

/**
 * @ author Seale
 * @ Description: Json返回包集合类
 * @ QQ:1801157108
 * @ Date 2020/2/1 19:18
 */
@Getter@Setter
@Accessors(chain = true)
@NoArgsConstructor
@AllArgsConstructor
public class ReturnsKit <T>{
    //状态码
    private Integer code;
    //结果
    private T result;
    //消息
    private String message;
    //状态
    private String status;
}

结果返回工具类

package com.eendtech.witkey.utils;

import com.eendtech.witkey.constants.BaseConstant;

import javax.servlet.http.HttpServletResponse;

/**
 * @ author Seale
 * @ Description: 结果返回工具类
 * @ QQ:1801157108
 * @ Date 2020/2/6 12:23
 */

public class ResultUtils {
    @SuppressWarnings("unchecked")
    public static <T> ReturnsKit<T> success(Object result) {
        ReturnsKit returnsKit = new ReturnsKit();
        return returnsKit.setCode(BaseConstant.Returns.SUCCESS.getCode())
                .setStatus(BaseConstant.Returns.SUCCESS.getStatus())
                .setResult(result);
    }

    public static ReturnsKit<String> success(String msg) {
        return new ReturnsKit<String>()
                .setCode(BaseConstant.Returns.SUCCESS.getCode())
                .setStatus(BaseConstant.Returns.SUCCESS.getStatus())
                .setMessage(msg);
    }
    public static ReturnsKit<String> erro(BaseConstant.Returns returns,String msg){
        return new ReturnsKit<String>().setCode(returns.getCode())
                .setStatus(returns.getStatus())
                .setMessage(msg);
    }
    public static ReturnsKit<String> erro(BaseConstant.Returns returns,BaseConstant.ReturnMessage message){
        return new ReturnsKit<String>().setCode(returns.getCode())
                .setStatus(returns.getStatus())
                .setMessage(message.getVal());
    }
    public static void initResponse(HttpServletResponse response){
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=utf-8");
    }
}

实现UserDetailService

package com.eendtech.witkey.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.eendtech.witkey.mapper.UserMapper;
import com.eendtech.witkey.model.JwtUser;
import com.eendtech.witkey.model.User;
import com.eendtech.witkey.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

/**
 * @ author Seale
 * @ Description:
 * @ QQ:1801157108
 * @ Date 2020/2/7 19:48
 */
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    @Autowired
    private UserService service;
    @Autowired
    private UserMapper mapper;
    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(User::getUsername,s);
        User user = service.getOne(wrapper);
        String groupName = mapper.getUserGroup(user.getId());
        return new JwtUser(user, groupName);
    }
}

我们知道,SpringSecurity框架自身需要调用这个服务类来查询我们的用户的权限相关信息,所以我们要实现它来供框架调用

其中的JWTUser是我对User基本对象的进一步包装,同时这个需要对UserDetails进行实现

package com.eendtech.witkey.model;

import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.Collections;

/**
 * @ author Seale
 * @ Description:
 * @ QQ:1801157108
 * @ Date 2020/2/7 21:10
 */
@Getter
@ToString
public class JwtUser implements UserDetails {

    private Integer id ;
    private String username;
    private String password;
    private Integer groupId;
    private String groupName;
    //鉴权用的,该用户具有哪些权限,目前我暂时存放我的groupName
    private Collection<? extends GrantedAuthority> authorities;

    public JwtUser(User user,String groupName) {
        this.id = user.getId();
        this.username = user.getUsername();
        this.password = user.getPassword();
        this.groupId = user.getGroupId();
        this.groupName = groupName;
        this.authorities = Collections.singleton(new SimpleGrantedAuthority(groupName));
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    //帐号是否未过期
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    //帐号是否未锁定
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }
    //帐号凭证是否未过期
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    // 是否开启
    @Override
    public boolean isEnabled() {
        return true;
    }

}

对应表的sql脚本

CREATE TABLE `user` (
  `id` int(255) unsigned NOT NULL AUTO_INCREMENT COMMENT 'id',
  `username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户名',
  `password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '密码\r\n',
  `groupId` int(2) unsigned NOT NULL DEFAULT '0' COMMENT '0 -> 普通用户 1-> 管理组',
  `email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '邮件地址',
  `createTime` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '创建时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `onlyUser` (`username`) COMMENT '用户名唯一',
  KEY `group` (`groupId`),
  CONSTRAINT `用户组` FOREIGN KEY (`groupId`) REFERENCES `groups` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE `groups` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL COMMENT '用户组名',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

UserDetailsServiceImpl中我们进行了groupName查询,并且将它放进JWTUser以便之后进行get()

实现两大过滤器(核心)

JWTAuthenticationFilter

其实,在SpringSecurity中最核心的部分也就是两大过滤器的编写了

我们首先完成 JWTAuthenticationFilter,也就是我们平时所说的获取token接口/登录接口

我们通过Filter实现token的生成并判断用户的凭据是否正确,也就是框架本身调用的UserDetailsService这个类,与之相关联的也就是我们实现的UserDetailsJwtUser

package com.eendtech.witkey.Filter;

import com.eendtech.witkey.constants.BaseConstant;
import com.eendtech.witkey.model.JwtUser;
import com.eendtech.witkey.model.User;
import com.eendtech.witkey.utils.JwtUtils;
import com.eendtech.witkey.utils.ResultUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

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.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;

/**
 * @ author Seale
 * @ Description: JJWT过滤器
 * @ QQ:1801157108
 * @ Date 2020/2/7 18:43
 */

/** 验证用户名密码正确之后生成一个token并且返回给客户端
 *  我们需要重写attemptAuthentication successfulAuthentication
 */
public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private AuthenticationManager authenticationManager ;

    public JWTAuthenticationFilter(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
        super.setFilterProcessesUrl("/api/oauth/login");
    }

    // 接受并解析用户的凭证
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
                                                HttpServletResponse response) throws AuthenticationException {
        try {
            User user = new ObjectMapper().readValue(request.getInputStream(),User.class);
            System.err.println(user.getUsername()+"\t"+user.getPassword());
            return authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(user.getUsername(),user.getPassword(),new ArrayList<>())
                    //这里的ArrayList是权限列表,本项目在里面只存取了用户组信息
            );
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        }
    }

    //成功登录后 该方法被调用
    @Override
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication authResult) throws IOException, ServletException {
        JwtUser jwtUser = (JwtUser) authResult.getPrincipal();
        System.err.println("成功登录: -> \n"+"\t"+jwtUser);
        //生成一个永久令牌
        Collection<? extends GrantedAuthority> authorities = jwtUser.getAuthorities();
        String group = "";
        for (GrantedAuthority authority : authorities){
            group = authority.getAuthority();
        }
        String token = JwtUtils.GenToken(jwtUser.getUsername(),jwtUser.getId(),group,true);
        System.err.println(token);
        response.setHeader(BaseConstant.Oauth.HEADER_PARAM.getVal(), BaseConstant.Oauth.TOKEN_PREFIX.getVal()+token);
        ResultUtils.initResponse(response);
        Map<String,String> r = new HashMap<>();
        r.put("token",token);
        response.getWriter().write(new ObjectMapper().writeValueAsString(ResultUtils.success(r)));
    }
    // 这是验证失败时候调用的方法
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        response.getWriter().write(new ObjectMapper().writeValueAsString(ResultUtils.erro(BaseConstant.Returns.FAIL, BaseConstant.ReturnMessage.USER_OR_PWD_ERR)));
    }
}

我在这里简单讲解以下,attemptAuthentication这个是我们进行登录鉴权用的接口,首先查看代码,我在里面通过JacksonObjectMapper对JSON进行序列化,我们拿到了usernamepassword

之后我们将它们提交给框架进行处理,也就是

return authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(user.getUsername(),user.getPassword(),new ArrayList<>())

这一句,因为此时权限列表是空的(还没有登录成功),所以我们生成一个空的列表或者填写null

successfulAuthentication这个接口是登录成功后调用,上面我们通过框架将我们的用户信息提交给了我们之前实现的服务类,进行了查询,所以我们在这里取得我们的JwtUser即可,由于框架返回的是Object类型,所以我们在这里需要进行一下强转

之后我们便通过我们封装的工具类生成一个永久token(这个根据自己的需求来进行更改,由于这个只是我的毕设项目,所以..),如果你想进行有效的管理token可以尝试进行集合redis,在这里我就不多说明了

生成成功之后我们通过返回工具进行内容的返回,官方建议的是返回到Header中,在上面的DEMO中我实现了两种方式,一种通过responseBody返回内容,一种加入到responseHeader中

JWTAuthorizationFilter

这个就是我们的鉴权接口了,我们拿到Token之后请求我们所需要的接口,便会调用这个Filter

package com.eendtech.witkey.Filter;

import com.eendtech.witkey.constants.BaseConstant;
import com.eendtech.witkey.utils.BaseUtils;
import com.eendtech.witkey.utils.JwtUtils;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;

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.Collections;

/**
 * @ author Seale
 * @ Description: Token的校验
 * @ QQ:1801157108
 * @ Date 2020/2/7 22:08
 */
public class JWTAuthorizationFilter extends BasicAuthenticationFilter {
    public JWTAuthorizationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);

    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws IOException, ServletException{
        String header = request.getHeader(BaseConstant.Oauth.HEADER_PARAM.getVal());
        if (header == null || !header.startsWith(BaseConstant.Oauth.TOKEN_PREFIX.getVal())) {
            chain.doFilter(request, response);
            return;
            //请求头没有token则直接放行
        }
        //进行解析
        //先对header进行验证

        /*try {
            JwtUtils.validateJwt(header.replace(BaseConstant.Oauth.TOKEN_PREFIX.getVal(), ""));
        }catch (IllegalArgumentException e){
            if (e.getMessage().equals("JWT String argument cannot be null or empty.")){
                ResultUtils.initResponse(response);
                response.getWriter().write(new ObjectMapper().writeValueAsString(ResultUtils.erro(BaseConstant.Returns.TOKEN_PARSE_FAILED,"Token解析器解析失败,请携带正确的token")));
                return;
            }
        }*/

        SecurityContextHolder.getContext().setAuthentication(getAuthentication(header));

        super.doFilterInternal(request, response, chain);
    }

    private UsernamePasswordAuthenticationToken getAuthentication(String tokenHeader) {
        String token = tokenHeader.replace(BaseConstant.Oauth.TOKEN_PREFIX.getVal(), "");

        String username = JwtUtils.getUserName(token);
        String group = JwtUtils.getGroup(token);

        if (BaseUtils.verifyParam(username)) return new
                UsernamePasswordAuthenticationToken(username, null, Collections.singleton(new SimpleGrantedAuthority(group)));
        return null;
    }
}

这个没有什么难度,我们先验证header是否为空,当然这里你也可以改成获取token参数

如果请求头没有token我们直接放行,框架会自行拦截

我们通过SecurityContextHolder.getContext().setAuthentication(getAuthentication(header));来将我们所有的权限加入到Security域中,这样框架本身就会调用UsernamePasswordAuthenticationToken(username, null, Collections.singleton(new SimpleGrantedAuthority(group))中的权限列表来识别我们的身份了

securityConfig

然后就是对我们的security进行配置了如下


package com.eendtech.witkey.config;

import com.eendtech.witkey.Filter.JWTAuthenticationFilter;
import com.eendtech.witkey.Filter.JWTAuthorizationFilter;
import com.eendtech.witkey.advice.JWTAccessDeniedHandler;
import com.eendtech.witkey.advice.JWTAuthenticationEntryPoint;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

/**
 * @ author Seale
 * @ Description: Spring Security Configureation
 * @ QQ:1801157108
 * @ Date 2019/11/29 22:26
 */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    @Qualifier(value = "userDetailsServiceImpl")
    private UserDetailsService userDetailsService;

    @Autowired
    JWTAccessDeniedHandler jwtAccessDeniedHandler;

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //对请求进行验证
        http.cors().and().csrf().disable()
                .authorizeRequests()
                // 对本接口的所有请求进行放行
                .antMatchers(HttpMethod.POST,"/api/order/**").authenticated()
                .antMatchers(HttpMethod.POST,"/api/message/**").authenticated()
                .antMatchers("/api/message/sendSys").hasAnyAuthority("ROLE_ADMIN")
                .antMatchers("/api/message/updateSysMsg").hasAnyAuthority("ROLE_ADMIN")
                .antMatchers("/api/message/getAllSysForGroup").hasAnyAuthority("ROLE_ADMIN")
                .antMatchers("/api/message/sendGroupSysMsg").hasAnyAuthority("ROLE_ADMIN")
                .antMatchers("/api/message/sendNotice").hasAnyAuthority("ROLE_ADMIN")
                .anyRequest().permitAll()
                .and()
                .addFilter(new JWTAuthenticationFilter(authenticationManager()))
                .addFilter(new JWTAuthorizationFilter(authenticationManager()))
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)//不需要session
                .and()
                .exceptionHandling().authenticationEntryPoint(new JWTAuthenticationEntryPoint())
                .accessDeniedHandler(jwtAccessDeniedHandler)

        ;

    }

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", new CorsConfiguration().applyPermitDefaultValues());
        return source;
    }

}

在这里进行配置哪些需要验证,哪些需要特定的权限

值得注意的是

                .addFilter(new JWTAuthenticationFilter(authenticationManager()))
                .addFilter(new JWTAuthorizationFilter(authenticationManager()))

这两个过滤器的顺序不能改变

进行异常的捕捉

完成上面的步骤就已经基本完成了对RestFul接口的鉴权了,但是如果我们携带错误或者没有携带Token进行请求接口的时候,那么后台返回的数据则是默认的异常处理页,这显然在业务开发中是不能忽视的

所以我们需要自己定义一些Handler来进行处理

package com.eendtech.witkey.advice;

import com.eendtech.witkey.constants.BaseConstant;
import com.eendtech.witkey.utils.ResultUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @ author Seale
 * @ Description: 认证接口处理 -> 没有携带Token,Token校验失败
 * @ QQ:1801157108
 * @ Date 2020/2/8 10:18
 */
public class JWTAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest httpServletRequest,
                         HttpServletResponse httpServletResponse,
                         AuthenticationException e) throws IOException, ServletException {
        httpServletResponse.setCharacterEncoding("UTF-8");
        httpServletResponse.setContentType("application/json; charset=utf-8");

        httpServletResponse.getWriter().write(new ObjectMapper()
                .writeValueAsString(ResultUtils.erro(BaseConstant.Returns.NOTAUTH, BaseConstant.ReturnMessage.NOT_FOUND_TOKEN)));
    }
}
package com.eendtech.witkey.advice;

import com.eendtech.witkey.constants.BaseConstant;
import com.eendtech.witkey.utils.ResultUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @ author Seale
 * @ Description: 没有访问权限
 * @ QQ:1801157108
 * @ Date 2020/2/8 10:31
 */
@Component
public class JWTAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
        httpServletResponse.setCharacterEncoding("UTF-8");
        httpServletResponse.setContentType("application/json; charset=utf-8");
        httpServletResponse.getWriter().write(new ObjectMapper()
                .writeValueAsString(ResultUtils.erro(BaseConstant.Returns.NOTAUTH, BaseConstant.ReturnMessage.NOT_OAUTH_REQUEST)));
    }
}

以上是Security框架自带的异常处理,之后我们通过Controller AOP编程来进行增强操作,在SpringBoot中已经为我们实现了Controller的Advice

我们可以通过注解直接使用@RestControllerAdvice

由于这里我们是restful接口所以使用的上面的注解

package com.eendtech.witkey.advice;

import com.eendtech.witkey.Exception.TokenException;
import com.eendtech.witkey.constants.BaseConstant;
import com.eendtech.witkey.utils.ResultUtils;
import com.eendtech.witkey.utils.ReturnsKit;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
 * @ author Seale
 * @ Description: Controller处理增强
 * @ QQ:1801157108
 * @ Date 2020/2/6 13:10
 */
@RestControllerAdvice
public class ExceptionControllerAdvice {

    @ExceptionHandler(value = Exception.class)
    public ReturnsKit<String> SimpleExceptionHandler(Exception e){
        return ResultUtils.erro(BaseConstant.Returns.UNKNOWN_ERROER,
                e.getMessage() == null? e.getClass().getName() : e.getMessage());
    }

    @ExceptionHandler(value = TokenException.class)
    public ReturnsKit<String> tokenParseFail(TokenException e){
        return ResultUtils.erro(e.getReturns(),e.getMsg());
    }
}

到这里,也就是我上文留下的坑了,这里我们定义了一个TokenException异常类

下面我们来看看这个异常类

package com.eendtech.witkey.Exception;

import com.eendtech.witkey.constants.BaseConstant;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;

/**
 * @ author Seale
 * @ Description:
 * @ QQ:1801157108
 * @ Date 2020/2/8 10:58
 */
@Getter
@Setter
@Accessors(chain = true)
public class TokenException extends RuntimeException {
    private static final long serialVersionUID = -739183828263221695L;
    private BaseConstant.Returns returns ;
    private String msg ;
    public TokenException(BaseConstant.Returns returns,String msg){
        super(returns.getStatus());
        this.returns = returns;
        this.msg = msg;
    }

}

启动项目后,你会发现,如果我输入错误的Token还是没有进行异常的捕获,这是为什么呢?

因为在Spring中Filter是最外层,执行顺序如下

Filter > ControllerAdvice > Controller

如此可见,就算我们定义了捕获异常的增强Controller但是依旧不能捕获Filter所抛出的异常

这里提出一个我的解决方案

我们可以重写Spring默认的基本异常捕获,在这里面再抛出我们自己所定义的异常,这样做的弊端,就是我们需要将可发现的异常全部定义,并且规范化的返回,否则或许会返回空文本在异常捕获数据中

package com.eendtech.witkey.controller;

import com.eendtech.witkey.Exception.TokenException;
import com.eendtech.witkey.constants.BaseConstant;
import org.springframework.boot.autoconfigure.web.ErrorProperties;
import org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController;
import org.springframework.boot.web.servlet.error.DefaultErrorAttributes;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import java.util.Map;

/**
 * @ author Seale
 * @ Description: 全局异常转发抛出
 * @ QQ:1801157108
 * @ Date 2020/2/8 14:30
 */
@RestController
public class ErrorController extends BasicErrorController {
    public ErrorController() {
        super(new DefaultErrorAttributes(), new ErrorProperties());
    }
    @RequestMapping(produces = {MediaType.APPLICATION_JSON_VALUE})
    public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
        Map<String, Object> body = getErrorAttributes(request, isIncludeStackTrace(request, MediaType.ALL));
        //自定义的错误信息类
        //status.value():错误代码,
        //body.get("message").toString()错误信息
        if (!body.get("message").toString().isEmpty()) {
            //在这里可以进行更为详细的定制,当前项目的需求只是捕获Filter中的token解析异常
            String msg = body.get("message").toString();
            throw new TokenException(BaseConstant.Returns.TOKEN_PARSE_FAILED,msg);
        }
        return null;
    }

}

流程相当于这样:

首先我们的Filter抛出了异常 -> 进入到了Spring默认的ERRO捕获Controller -> 由于我们的重写进行抛出自定义的Exception -> 我们所定义的ControllerAdvice增强进行异常的捕获

测试

参考文献

JWT官网

Protect REST APIs with Spring Security and JWT

REST Security with JWT using Java and Spring Security

Spring Security Reference Doc


本作品采用知识共享署名 4.0 国际许可协议进行许可。

如果可以的话,请给我钱请给我点赞赏,小小心意即可!

Last modification:February 8, 2020
If you think my article is useful to you, please feel free to appreciate