Spring Security的项目中集成JWT Token令牌安全访问后台API

1,585 阅读15分钟

引言

最近接了一个私活项目,后台使用的是Spring Boot脚手架搭建的,认证和鉴权框架用的Spring Security。同时为了确保客户端安全访问后台服务的API,需要用户登录成功之后返回一个包含登录用户信息的jwt token, 用于调用其他接口时将此jwt token携带在请求头中作为调用者的认证信息。最近一个多月一方面在忙着做这个项目,另一方面恰好遇上了精彩的世界杯,也没怎么发文了。很多时候真的深感写篇原创文章比单纯的敲代码麻烦多了,但是好久不更文还是要检讨一下自己的惰性,客服自身的惰性是每个想要突破自我、不甘平庸的普通人的一辈子都不能松懈的重任。

JWT 简介

首先,让我们来补一下jwt的知识。jwt token 的全称叫JSON Web Token ,主要用于在各方之间以JSON 对象方式安全地传输信息。此信息是数字签名的,可以验证和信任,JWT 可以使用密钥(使用 HMAC 算法)或使用 RSAECDSA 的公钥/私钥对进行签名。

虽然 JWT 可以加密以在各方之间提供保密性,但我们将专注于签名令牌。签名的令牌可以验证其中包含的声明的完整性,而加密的令牌会向其他方隐藏这些声明。当使用公钥/私钥对对令牌进行签名时,只有持有私钥的一方才可以签署。

jwt token 的适用场景

  • 鉴权(Authorization):这是最常见的场景。用户登录后,每个后续请求都将包含 JWT,从而允许用户访问该令牌允许的路由、服务和资源。 单点登录是当今广泛使用 JWT 的一项功能,因为它的开销很小并且能够在不同的域中轻松使用。

  • 信息交换(Information Exchange):JWT令牌是在各方之间安全传输信息的好方法。 因为可以对 JWT 进行签名(例如,使用公钥/私钥对),所以可以确定发件人就是他们所说的那个人。此外,由于使用 headerpayload 计算签名,还可以验证内容是否被篡改。

jwt 的结构

JWT 由headerpayloadsignature三部分组成,以 . 分割:

  • header:通常由令牌的类型(即 JWT)以及正在使用的签名算法(例如 HMAC SHA256 或 RSA)两部分组成;

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

    然后,这个 JSON 被 Base64Url 编码以形成 JWT 的第一部分。

  • payload: 有效负载。其中包含声明,声明是关于实体(通常是用户)和附加数据的陈述。 声明分为三种类型:registered, public, private claims.

    注册(registered)声明:这是一组预定义的声明,不是强制性的,但建议使用。以提供一组有用的、可互操作的声明。比如:issue(发行人)、expired(到期时间)、subject(主题)、aud(受众)等。

    公共(public)声明:这些可以由使用人随意定义。 但是为了避免冲突,应该在jwt token 注册中定义,或者定义为包含抗冲突命名空间的 URI。

    私有(private)声明:这些是为在同意使用它们的各方之间共享信息而创建的自定义声明,既不是注册声明也不是公共声明。

    示例如下:

    {
      "sub": "1234567890",
      "name": "John Doe",
      "iat": 1516239022
    }
    

    然后对有效负载进行 Base64Url 编码以形成 JSON Web 令牌的第二部分

    注意,对于已签名的令牌,此信息虽然受到保护以防篡改,但任何人都可以读取。除非已加密,否则请勿将机密信息放入 JWT 的有效负载或标头元素中。

  • Signature: 要创建签名部分,必须获取已编码的标头(header)、编码的有效负载(payload)、密钥、header中指定的算法,并对其进行签名。

​ 例如,如果您想使用 HMAC SHA256 算法,签名将通过以下方式创建:

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

签名用于验证信息在传输过程中是否被篡改,并且在使用私钥签名令牌的情况下,它还可以验证 JWT 的发送者是否正确。

完整jwt

由三个 . 分隔的 Base64-URL 字符串,可以在 HTML 和 HTTP 环境中轻松传递,相对于基于 XML 的标准(如 SAML)则更紧凑。

下面显示了一个 JWT,该 JWT 具有前面介绍过的headerpayload编码,并使用密钥签名

jwt_token.png

我们可以在 jwt.io Debugger 网站来解码、验证和生成 JWT。

gen_jwt_token.png

jwt 的使用方式

在身份校验中,当用户成功登录,将返回一个 JSON Web Token。由于令牌是凭据,因此必须非常小心以防止出现安全问题。

通常令牌需要设置一个过期时间,超过过期时间则令牌失效,需要置换新的令牌。

由于缺乏安全性,不应该将敏感的会话数据存储在浏览器中。每当用户需要访问受保护的路由或资源时,用户代理应该发送jwt,通常在 Authorization header 中使用 Bearer 模式。 header 的内容应如下所示:

Authorization: Bearer <token>

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

如果 tokenAuthorization header,跨域 Cross-Origin Resource Sharing (CORS)不是问题,因为它不使用 cookies

客户端获取jwt令牌访问受保护资源的具体流程

jwt_auth_principle.png

1) 用户在在客户端使用用户名/密码登录;

2)服务端使用密钥生成一个JWT令牌;

3)服务端将生存的jwt令牌返回给浏览器;

4)用户拿到jwt 令牌放到Authentication参数对应的请求头中访问服务端受保护的资源和API;

5)服务端校验签名,从jwt令牌中解析获取用户信息;

6)服务端校验签名通过并从jwt令牌中解析出用户信息,则返回API的成功响应信息给客户端

Spring Security 安全框架下使用jwt token

在非spring security框架下的spring boot项目中使用jwt令牌鉴权,我们只需要新建一个拦截器或者Servlet过滤器解析jwt token信息就行了,解析成功就放行请求,解析失败则返回403权限不足信息就行了。但是在Spring Security 框架中本身就自动适配了很多个过滤器,并组成了一个过滤器链,因此我们也需要新建一个解析jwt token的过滤器加入过滤器链中才行。

新建一个spring boot项目

使用IDEA新建spring boot项目的同时添加一些必要的依赖jar包,如spring mvc、mysql驱动、druid数据源和fast-json及代码简洁工具lombok等

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.7.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.bonus</groupId>
    <artifactId>bonus-backend</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <name>bonus-backend</name>
    <description>bonus-backend</description>
    <properties>
        <java.version>1.8</java.version>
    </properties>
    <dependencies>
       <!--spring web mvc依赖 -->
       <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--修改配置文件自动生效依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
        <!--druid 数据源依赖-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.2.8</version>
        </dependency>
        <!--阿里json工具依赖 -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.79</version>
        </dependency>
        <!--mysql驱动包-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.16</version>
            <scope>runtime</scope>
        </dependency>
        <!--代码简洁工具lombok依赖-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>
     <build>
            <plugins>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                </plugin>
            </plugins>
        </build>
</project>

加入spring security 和 jwt 相关依赖项

在项目的pom.xml文件的dependencies标签中加入

        <!--加解密依赖-->
        <dependency>
            <groupId>commons-codec</groupId>
            <artifactId>commons-codec</artifactId>
            <version>1.11</version>
        </dependency>
        <!--持久层框架mybatis-plus依赖-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.1.2</version>
        </dependency>
       <!--spring security依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
            <version>2.2.7.RELEASE</version>
        </dependency>
        <!--jwt token依赖-->
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.7.0</version>
        </dependency>

项目配置文件

application-porperties

server.servlet.context-path=/bonus
spring.profiles.active=dev

spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
spring.jackson.time-zone=GMT+8

# mybatis-plus config
mybatis-plus.mapper-locations=classpath*:/mapper/**/*.xml
mybatis-plus.configuration.map-underscore-to-camel-case=true
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl

# twelve.zodiac
twelve.zodiac.mouse=03,15,27,39
twelve.zodiac.cow=02,14,26,38
twelve.zodiac.tiger=01,13,25,37,49
twelve.zodiac.rabbit=12,24,36,48
twelve.zodiac.dragon=11,23,35,47
twelve.zodiac.snake=10,22,34,46
twelve.zodiac.horse=09,21,33,45
twelve.zodiac.sheep=08,20,32,44
twelve.zodiac.monkey=07,19,31,43
twelve.zodiac.chicken=06,18,30,42
twelve.zodiac.dog=05,17,29,41
twelve.zodiac.pig=04,16,28,40

application-dev.properties

server.address=127.0.0.1
server.port=8090

spring.datasource.druid.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.druid.url=jdbc:mysql://localhost:3306/bonus?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
spring.datasource.druid.username=bonus_user
spring.datasource.druid.password=tiger2022@
spring.datasource.druid.validation-query=select 1 from dual
#spring.datasource.druid.connect-properties

# redis config
spring.redis.client-name=redis-client
spring.redis.host=127.0.0.1
spring.redis.port=6379
spring.redis.database=0

日志打印配置

log4j.properties

log4j.rootLogger=DEBUG,stdout
log4j.logger.com.baomidou.mybatisplus=DEBUG
log4j.appender.stdout=org.apache.log4j.ConsoleAppender
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
log4j.appender.stdout.layout.ConversionPattern=%5p %d %C: %m%n 

启动类

@SpringBootApplication
@EnableConfigurationProperties
@EnableGlobalMethodSecurity(prePostEnabled=true, securedEnabled=true, jsr250Enabled=true)
public class BonusBackendApplication {

    public static void main(String[] args) {
        SpringApplication.run(BonusBackendApplication.class, args);
    }

}

启动类中除了加上@SpringBootApplication注解之外,还加上了开启配置属性生效的注解@EnableConfigurationProperties以及全局安全访问注解@EnableGlobalMethodSecurity进行动态权限校验

JWT相关API

用于生成jwt token 和从 jwt token中解析出用户信息的相关API都在com.auth0.jwt.JWTcom.auth0.jwt.JWTCreator两个类中。

JWT 类中的API方法

  • public JWT(): JWT类的构造方法;

  • public static Builder create(): 创建jwt token的构建器, 返回对象为JWTCreator类中的静态内部类Builder

  • public DecodedJWT decodeJwt(String token): 解析jwt token方法

  • public static DecodedJWT decode(String token) : 静态解析jwt token方法

  • public static Verification require(Algorithm algorithm): 通过算法构造Verification对象静态方法, Verification类主要用来校验jwt令牌是否有效

JWTCreator类中的API方法

静态内部类Builder主要用于构造headerpayload中 的内容, 该静态类主要提供一些列withXXX方法用于指定相应的键值对内容,主要有一下API方法:

  • public JWTCreator.Builder withHeader(Map<String, Object> headerClaims): 构造header代表的键值对集合;
  • public JWTCreator.Builder withKeyId(String keyId): 指定令牌header中的kid的值;
  • public JWTCreator.Builder withIssuer(String issuer): 指定令牌发行者;
  • public JWTCreator.Builder withSubject(String subject): 指定令牌主题;
  • public JWTCreator.Builder withAudience(String... audience): 指定令牌受众,通过该方法可以将令牌授予有限数量的用户;
  • public JWTCreator.Builder withExpiresAt(Date expiresAt): 指定令牌过期日期;
  • public JWTCreator.Builder withNotBefore(Date notBefore): 指定令牌不能早于某个日期使用;
  • public JWTCreator.Builder withIssuedAt(Date issuedAt): 指定令牌签发日期;
  • public JWTCreator.Builder withJWTId(String jwtId): 指定令牌id;
  • public JWTCreator.Builder withClaim(String name, Boolean value): 指定payload中的键值对,值为布尔类型;
  • public JWTCreator.Builder withClaim(String name, Integer value): 指定payload中的键值对,值为Integer类型;
  • public JWTCreator.Builder withClaim(String name, Long value) : 指定payload中的键值对,值为Long类型;
  • public JWTCreator.Builder withClaim(String name, Double value): 指定payload中的键值对,值为Double类型;
  • public JWTCreator.Builder withClaim(String name, String value): 指定payload中的键值对,值为String类型;
  • public JWTCreator.Builder withClaim(String name, Date value): 指定payload中的键值对,值为Date类型;
  • public JWTCreator.Builder withArrayClaim(String name, String[] items): 指定payload中的键值对,值为String数组类型;
  • public JWTCreator.Builder withArrayClaim(String name, Integer[] items): 指定payload中的键值对,值为Integer数组类型;
  • public JWTCreator.Builder withArrayClaim(String name, Long[] items): 指定payload中的键值对,值为Long数组类型;
  • public String sign(Algorithm algorithm) : 签名方法,通过算法签名,得到完整的jwt token内容方法

algorithm算法对象可通过静态方法Algorithem#HMAC256或者Algorithem#HMAC512方法创建,入参为一个String类型的密钥

JWTDecoder类中的API方法

JWTDecoder类为DecodedJWT类的实现类,主要用来从解析jwt令牌后的对象中获取想要的字段信息

  • public String getAlgorithm(): 获取签名算法名称;

  • public String getType(): 获取jwt令牌的类型,默认为jwt;

  • public String getKeyId(): 获取jwt 令牌header中的kid对应的值;

  • public Claim getHeaderClaim(String name): 获取header中指定名字的Claim, 它可以进一步把value代表的数据转成各种数据类型;

  • public String getIssuer(): 获取jwt令牌的签发人;

  • public String getSubject():获取jwt令牌的主题;

  • public List<String> getAudience(): 获取jwt 令牌的受众;

  • public Date getExpiresAt(): 获取jwt令牌过期时间;

  • public Date getNotBefore(): 获取令牌不能早于使用的时间;

  • public String getId(): 获取令牌id;

  • public Claim getClaim(String name): 获取指定名字Claim

  • public Map<String, Claim> getClaims(): 获取jwt令牌中的Claim键值对集合;

  • public String getHeader(): 获取jwt令牌中的header部分内容;

  • public String getPayload(): 获取jwt令牌中的payload部分内容;

  • public String getSignature(): 获取jwt 令牌中签名部分内容;

  • public String getToken(): 还原jwt令牌内容;

新建Jwt令牌工具类

利用JWT相关API我们新建了一个JwtTokenUtil的工具类用于生成jwt令牌

public class JwtTokenUtil {

    // 密钥
    private static final String SECRET = "bonusBACKEND2022$";

    // 过期时间7天
    private static final int EXPIRE_SECONDS = 7*24*3600;

    private final static Logger logger = LoggerFactory.getLogger(JwtTokenUtil.class);

    /**
     * 生成token方法
     * @param memInfoMap
     * @return jwtToken
     */
    public static String genAuthenticatedToken(Map<String, Object> memInfoMap){
        List<GrantedAuthority> authorities = (List<GrantedAuthority>) memInfoMap.get("authorities");
        String authorityStr = null;
        if(authorities!=null && authorities.size()>0){
            StringBuffer buffer = new StringBuffer();
            for(int i=0; i<authorities.size()-1; i++){
                buffer.append(authorities.get(i).getAuthority()).append(",");
            }
            buffer.append(authorities.get(authorities.size()-1).getAuthority());
            authorityStr = buffer.toString();
        }
        String[] authorityArray = authorityStr!=null?authorityStr.split(","):null;
        Calendar nowTime = Calendar.getInstance();
        //过期时间
        nowTime.add(Calendar.SECOND, EXPIRE_SECONDS);
        Date expireDate = nowTime.getTime();
        String jwtToken = JWT.create().withJWTId(UUID.randomUUID().toString().replaceAll("-", ""))
                .withClaim("memId", (Long) memInfoMap.get("memId"))
                .withClaim("memAccount", (String) memInfoMap.get("memAccount"))
                .withClaim("memPwd", (String) memInfoMap.get("memPwd"))
                .withClaim("totalCreditAmount", ((BigDecimal) memInfoMap.get("totalCreditAmount")).doubleValue())
                .withClaim("usedCreditAmount", ((BigDecimal) memInfoMap.get("usedCreditAmount")).doubleValue())
                .withClaim("remainCreditAmount", ((BigDecimal) memInfoMap.get("remainCreditAmount")).doubleValue())
                .withArrayClaim("authorities", authorityArray)
                .withIssuedAt(new Date(System.currentTimeMillis()))
                .withExpiresAt(expireDate)
                .sign(Algorithm.HMAC256(SECRET));
        return jwtToken;
    }
}

实现用户认证方法

UserDetailService#loadUserByUsername

@Service
public class MemInfoServiceImpl extends ServiceImpl<MemInfoMapper, MemInfoDTO> implements MemInfoService {
 private final static Logger logger = LoggerFactory.getLogger(MemInfoServiceImpl.class);
    @Resource
    private MyPasswordEncoder passwordEncoder;
    @Resource
    private RoleInfoService roleInfoService;
    
    @Override
    @Transactional
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        MemInfoDTO memInfoDTO = this.baseMapper.getMemInfoByAccount(username);
        if(memInfoDTO==null){
            throw  new UsernameNotFoundException("Username" + username + "is invalid!");
        }
        // 获取用户角色列表
        List<RoleInfoDTO> roleInfoDTOList = roleInfoService.getRolesByMemId(memInfoDTO.getMemId());
        if(roleInfoDTOList.size()>0){
            for(RoleInfoDTO roleInfoDTO: roleInfoDTOList){
                SimpleGrantedAuthority grantedAuthority = new SimpleGrantedAuthority("ROLE_" + roleInfoDTO.getRoleName().toUpperCase());
                memInfoDTO.getAuthorities().add(grantedAuthority);
            }
        }
        return memInfoDTO;
    }

MemInfoDTO类源码如下:

@Data
@TableName("bonus_mem_info")
@ApiModel(value="MemInfoDTO", description = "会员DTO")
@Validated
public class MemInfoDTO extends BaseDTO implements UserDetails {

    /**
     * 会员id
     */
    @TableId
    @ApiModelProperty(name = "memId", value = "memId", notes = "会员ID", dataType = "Long")
    private Long memId;

    /**
     * 会员账号
     */
    @TableField(value = "mem_account")
    @NotEmpty(message = "会员账号不能为空")
    @ApiModelProperty(name="memAccount", value = "memAccount", notes = "会员账号", dataType = "String")
    private String memAccount;

    /**
     * 会员密码
     */
    @TableField(value = "mem_pwd")
    @NotEmpty(message = "会员密码不能为空")
    @ApiModelProperty(name="memPwd", value = "memPwd", notes = "加密后的会员密码", dataType = "String")
    private String memPwd;

    /**
     * 会员类型:1-vip;2-代理
     */
    @TableField(value = "mem_type")
    @NotEmpty(message = "会员类型不能为空")
    @ApiModelProperty(name="memType", value = "memType", notes = "会员类型", dataType = "Integer", example = "1", allowableValues = "1,2")
    private Integer memType;

    /**
     * 会员信用额度,单位分
     */
    @TableField(value = "total_credit_amount")
    @NotEmpty(message = "会员信用额度不能为空")
    @ApiModelProperty(name = "totalCreditAmount", value = "totalCreditAmount", notes = "会员总信用额度,单位分", dataType = "Long", example = "10000")
    private Long totalCreditAmount;

    /**
     * 会员已使用信用额度,单位分
     */
    @ApiModelProperty(name = "usedCreditAmount", value = "usedCreditAmount", notes = "会员已使用信用额度,单位分", dataType = "Long", example = "5000")
    @TableField(value = "used_credit_amount")
    private Long usedCreditAmount;

    @TableField(exist = false)
    private List<GrantedAuthority> authorities = new ArrayList<>();

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

    @Override
    public String getPassword() {
        return this.memPwd;
    }

    @Override
    public String getUsername() {
        return this.memAccount;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

新建JwtToken认证过滤器

public class JwtAuthenticationFilterBean extends GenericFilterBean {

    private final static Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilterBean.class);

    private String AUTHORIZATION_NAME = "Authorization";

    private String BEARER = "Bearer";

    private static List<String> whiteRequestList = new ArrayList<>();

    static {
        whiteRequestList.add("/bonus/member/checkSafetyCode");
        whiteRequestList.add("/bonus/login");
        whiteRequestList.add("/bonus/member/login");
        whiteRequestList.add("/bonus/common/kaptcha");
        whiteRequestList.add("/bonus/admin/login");
        whiteRequestList.add("/bonus/favicon.ico");
        whiteRequestList.add("/bonus/doc.html");
        whiteRequestList.add("/bonus/error");
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        logger.info("requestUrl="+request.getRequestURI());
        if(whiteRequestList.contains(request.getRequestURI()) || (request.getRequestURI().contains("admin/dist") &&
                request.getRequestURI().endsWith(".css") || request.getRequestURI().equals(".js") ||
                request.getRequestURI().endsWith(".png") || request.getRequestURI().endsWith("favicon.ico"))){
            // 如果是登录和安全码验证请求直接放行
            filterChain.doFilter(servletRequest, servletResponse);
            return;
        } else {
               String bearerToken = request.getHeader(AUTHORIZATION_NAME);
               if(StringUtils.isEmpty(bearerToken)||!bearerToken.startsWith(BEARER)){
                   printException(response, HttpStatus.UNAUTHORIZED.value(), "缺失jwt令牌或令牌格式错误");
                   return;
               }
               String authToken = bearerToken.substring(bearerToken.indexOf(BEARER)+BEARER.length()+1);
               if(StringUtils.isEmpty(authToken)){
                   String message = "http header Authorization is null, user Unauthorized";
                   response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                   response.setStatus(HttpStatus.UNAUTHORIZED.value());
                   this.printException(response, HttpStatus.UNAUTHORIZED.value(), message);
                   return;
               } else {
                   try {
                       DecodedJWT decodedJWT = JWT.decode(authToken);
                       Map<String, Claim> claimMap = decodedJWT.getClaims();
                       Claim expireClaim = claimMap.get("exp");
                       Date expireDate = expireClaim.asDate();
                       // 校验token 是否过期
                       if(expireDate.before(DateUtil.date(System.currentTimeMillis()))){
                           String message = "Authorization token expired";
                           this.printException(response, HttpStatus.UNAUTHORIZED.value(), message);
                           return;
                       }
                       Claim memAccountClaim = claimMap.get("memAccount");
                       if(memAccountClaim==null || StringUtils.isEmpty(memAccountClaim.asString())){
                           String message = "memAccount cannot be null";
                           response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                           response.setStatus(HttpStatus.UNAUTHORIZED.value());
                           this.printException(response, HttpStatus.UNAUTHORIZED.value(), message);
                           return;
                       }
                       // 请求头认证通过, 放行请求
                       filterChain.doFilter(servletRequest, servletResponse);
                   } catch (JWTDecodeException e) {
                       String message = "JWT decode authToken failed, caused by " + e.getMessage();
                       this.printException(response, HttpStatus.UNAUTHORIZED.value(), message);
                       return;
                   }
               }
        }

    }

    /**
     * 打印请求头认证失败信息
     * @param response
     * @param status
     * @param message
     * @throws IOException
     */
    private void printException(HttpServletResponse response, int status, String message) throws IOException {
        logger.error(message);
        response.setStatus(status);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
        PrintWriter printWriter = response.getWriter();
        ResponseResult<String> responseResult = ResponseResult.error(status, message);
        printWriter.write(JSONObject.toJSONString(responseResult));
        printWriter.flush();
        printWriter.close();
    }
}

Spring Security配置类中配置登录成功后返回jwt令牌

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final static Logger logger = LoggerFactory.getLogger(SecurityConfig.class);

    @Resource
    private MemInfoService memInfoService;

    private MathContext mathContext = new MathContext(2, RoundingMode.HALF_UP);

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

    @Override
    public void configure(WebSecurity web) {
        web.ignoring().antMatchers("/static/**","/index.html","/templates/**", "/admin/**", "/doc.html", "/webjars/**", "/v2/*", "/favicon.ico", "/swagger-resources");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        JwtAuthenticationFilterBean jwtAuthenticationFilterBean = new JwtAuthenticationFilterBean();
        http.addFilterBefore(jwtAuthenticationFilterBean, UsernamePasswordAuthenticationFilter.class); // 将JwtToken认证过滤器注册在登录认证过滤器之前
        // 配置跨域
        http.cors().configurationSource(corsConfigurationSource())
                .and().logout().invalidateHttpSession(true).logoutUrl("/member/logout").permitAll()
        ;
        http.authorizeRequests().antMatchers("/member/checkSafetyCode").permitAll()
                .antMatchers("/doc.html").permitAll()
                .antMatchers("/common/kaptcha").permitAll()
                .antMatchers("/admin/login").permitAll()
                .anyRequest().authenticated()
                .and().httpBasic()
                .and().formLogin()
                .loginProcessingUrl("/member/login") // 登录接口
                .successHandler((httpServletRequest, httpServletResponse, authentication) -> {
                     httpServletResponse.setContentType("application/json;charset=utf-8");
                     httpServletResponse.setStatus(HttpStatus.OK.value());
                     PrintWriter printWriter = httpServletResponse.getWriter();
                     MemInfoDTO memInfoDTO = (MemInfoDTO) authentication.getPrincipal();
                     Map<String, Object> userMap = new HashMap<>();
                     userMap.put("memId", memInfoDTO.getMemId());
                     userMap.put("memAccount", memInfoDTO.getMemAccount());
                     userMap.put("memPwd", memInfoDTO.getMemPwd());
                     BigDecimal totalCredit = memInfoDTO.getTotalCreditAmount()!=null?new BigDecimal(memInfoDTO.getTotalCreditAmount()/100, mathContext): new BigDecimal("0.0");
                     userMap.put("totalCreditAmount", totalCredit);
                     BigDecimal usedCredit = memInfoDTO.getUsedCreditAmount()!=null?new BigDecimal(memInfoDTO.getUsedCreditAmount()/100, mathContext):new BigDecimal("0.0");
                     userMap.put("usedCreditAmount", usedCredit);
                     Long remainCredit = (memInfoDTO.getTotalCreditAmount()==null?0:memInfoDTO.getTotalCreditAmount()) - (memInfoDTO.getUsedCreditAmount()==null?0:memInfoDTO.getUsedCreditAmount());
                     BigDecimal remainCreditAmount = new BigDecimal(remainCredit/100, mathContext);
                     userMap.put("remainCreditAmount", remainCreditAmount);
                     userMap.put("authorities", memInfoDTO.getAuthorities());
                     Map<String, Object> dataMap = new HashMap<>();
                     dataMap.put("memInfo", userMap);
                     dataMap.put("authenticatedToken", "Bearer "+JwtTokenUtil.genAuthenticatedToken(userMap));
                     ResponseResult<Map<String, Object>> responseResult = ResponseResult.success(dataMap, "login success");
                     printWriter.write(JSONObject.toJSONString(responseResult));
                     printWriter.flush();
                     printWriter.close();
                }).failureHandler((httpServletRequest, httpServletResponse, e) -> {
                     logger.error("login failed, caused by " + e.getMessage());
                     httpServletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
                     httpServletResponse.setStatus(HttpStatus.OK.value());
                     PrintWriter printWriter = httpServletResponse.getWriter();
                     ResponseResult<String> responseResult = ResponseResult.error(HttpStatus.UNAUTHORIZED.value(), "authentication failed");
                     responseResult.setPath(httpServletRequest.getRequestURI());
                     printWriter.write(JSONObject.toJSONString(responseResult));
                     printWriter.flush();
                     printWriter.close();
                }).permitAll()
                .and().csrf().disable().exceptionHandling().accessDeniedHandler(accessDeniedHandler());

    }

    //配置跨域访问资源
    private CorsConfigurationSource corsConfigurationSource() {
        UrlBasedCorsConfigurationSource source =   new UrlBasedCorsConfigurationSource();
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.addAllowedOrigin("*");	//同源配置,*表示任何请求都视为同源,若需指定ip和端口可以改为如“localhost:8080”,多个以“,”分隔;
        corsConfiguration.addAllowedHeader("*");//header,允许哪些header,本案中使用的是token,此处可将*替换为token;
        corsConfiguration.addAllowedMethod("*");	//允许的请求方法,PSOT、GET等
        corsConfiguration.setAllowCredentials(true);
        // 注册跨域配置
        source.registerCorsConfiguration("/**",corsConfiguration); //配置允许跨域访问的url
        return source;
    }

    @Bean
    AccessDeniedHandler accessDeniedHandler() {
        return new AuthenticationAccessDeniedHandler();
    }
}

测试效果

在启动类中运行Main方法运行服务后就可以测试效果了

测试生成jwt令牌

我们首先测试生成jwt token的登录接口, 在postman中调用登录接口

post http://localhost:8090/bonus/member/login??username=zhangsan&password=zhangsan1234

接口返回信息如下:

{
    "code": 200,
    "data": {
        "memInfo": {
            "memAccount": "zhangsan",
            "totalCreditAmount": 2000,
            "memPwd": "82dea760d7bb362ca74883836ee4d6ba",
            "remainCreditAmount": 2000,
            "usedCreditAmount": 0,
            "authorities": [
                {
                    "authority": "ROLE_USER"
                }
            ],
            "memId": 1592927262097924097
        },
        "authenticatedToken": "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZW1BY2NvdW50IjoiemhhbmdzYW4iLCJ0b3RhbENyZWRpdEFtb3VudCI6MjAwMC4wLCJtZW1Qd2QiOiI4MmRlYTc2MGQ3YmIzNjJjYTc0ODgzODM2ZWU0ZDZiYSIsInJlbWFpbkNyZWRpdEFtb3VudCI6MjAwMC4wLCJ1c2VkQ3JlZGl0QW1vdW50IjowLjAsImV4cCI6MTY3MjU1ODAyMSwiaWF0IjoxNjcxOTUzMjIxLCJqdGkiOiI2M2M1YmExZDIzZGY0YjIzODQ1NWU5YjkwNzQzMzRmMSIsImF1dGhvcml0aWVzIjpbIlJPTEVfVVNFUiJdLCJtZW1JZCI6MTU5MjkyNzI2MjA5NzkyNDA5N30.S5UQLasL-SALKBHwhhUk_DGv__YPlRJQ7TC1pBzxb0g"
    },
    "message": "login success"
}

memPwd字段为密码加密后的密文

authenticatedToken 对应的内容为Bearer模式的jwt令牌, 真正的jwt令牌内容为eyj开头的那串较长的字符串。

测试通过jwt令牌认证与鉴权

新建一个获取配置数据的接口

@RestController
@RequestMapping("/config")
public class ConfigController {

    @Resource
    private ZodiacProperties zodiacProperties;

    @GetMapping("/twelve/zodiacs")
    public ResponseResult<ZodiacProperties> getTwelveZodiacs(){

        return ResponseResult.success(zodiacProperties);
    }

}

ZodiacProperties类源码如下:

@Component
@ConfigurationProperties(prefix = "twelve.zodiac")
public class ZodiacProperties {

    private String mouse;

    private String cow;

    private String tiger;

    private String rabbit;

    private String dragon;

    private String snake;

    private String horse;

    private String sheep;

    private String monkey;

    private String chicken;

    private String dog;

    private String pig;
    // 省略set、get方法
}

接口写好后,重启后台服务,并重新登录拿到jwt令牌令牌

首先试一下不在请求头中加入jwt令牌的结果

GET http://localhost:8090/bonus/config/twelve/zodiacs

接口返回结果:

{
    "code": 401,
    "message": "缺失jwt令牌或令牌格式错误"
}

然后在请求头中加入Authentication参数jwt令牌再次测试结果

zodiacs_api.png

此时返回结果:

{
    "code": 200,
    "message": "ok",
    "path": null,
    "data": {
        "mouse": "03,15,27,39",
        "cow": "02,14,26,38",
        "tiger": "01,13,25,37,49",
        "rabbit": "12,24,36,48",
        "dragon": "11,23,35,47",
        "snake": "10,22,34,46",
        "horse": "09,21,33,45",
        "sheep": "08,20,32,44",
        "monkey": "07,19,31,43",
        "chicken": "06,18,30,42",
        "dog": "05,17,29,41",
        "pig": "04,16,28,40"
    }
}

关于如何在集成spring security安全访问框架的spring boot项目中如何使用jwt令牌安全访问服务端API就讲到这里,需要项目源码的读者朋友关注笔者的微信公众号【阿福谈Web编程】,并发送关键字信息【bonus-backend】即可获得项目源码下载地址。

参考文章

[1] JWT token 介绍