Loading

SpringSecurity系列学习(四):基于JWT的认证

系列导航

SpringSecurity系列

SpringSecurityOauth2系列

基于JWT的认证

代码参考

憋嗦话,上号!

咳咳,在上号之前,再聊两句。。。

分布式认证

分布式认证,即我们常说的单点登录,简称SSO,指的是在多应用系统的项目中,用户只需要登录一次,就可以访问所有互相信任的应用系统。

但是首先,我们要明确,在分布式项目中,每台服务器都有各自独立的session,而这些session之间是无法直接共享资源的,所以,session通常不能被作为单点登录的技术方案。
最合理的单点登录方案流程如下图所示:

总结一下,单点登录的实现分两大环节:

  • 用户认证:这一环节主要是客户端向认证服务器发起认证请求,认证服务器给客户端返回一个成功的令牌token,主要在认证服务器中完成,认证服务器只能有一个。
  • 身份校验:这一环节是客户端携带token去访问其他服务器时,在其他服务器中要对token的真伪进行检验,主要在资源服务器中完成,资源服务器可以有很多个。

JWT

从分布式认证流程中,我们不难发现,这中间起最关键作用的就是token,token的安全与否,直接关系到系统的健壮性,这里我们选择使用JWT来实现token的生成和校验。

JWT,全称JSON Web Token,官网地址https://jwt.io 是一款出色的分布式身份校验方案。可以生成token,也可以解析检验token。

JWT生成的token由三部分组成:

  • 头部(header):主要设置一些规范信息,签名部分的编码格式就在头部中声明(用的什么加密算法)。
  • 载荷(payload):token中存放有效信息的部分,比如用户名,用户角色,过期时间等,但是不要放密码,会泄露!
  • 签名(sign):将头部与载荷分别采用base64编码后,用“.”相连,再加入盐,最后使用头部声明的编码类型进行编码,就得到了签名。

其中JWT在荷载中已经声明的字段:

  • lss:签发者
  • exb:过期时间
  • sub:主题
  • aud:目标受众
  • ...

还可以使用 Jwts.claim(key,value)添加字段

从JWT生成的token组成上来看,要想避免token被伪造,主要就得看签名部分了,而签名部分又有三部分组成,其中头部和载荷的base64编码,几乎是透明的,毫无安全性可言,那么最终守护token安全的重担就落在了加入的盐上面了!

试想:如果生成token所用的盐与解析token时加入的盐是一样的。岂不是类似于中国人民银行把人民币防伪技术公开了?大家可以用这个盐来解析token,就能用来伪造token。

这时,我们就需要对盐采用非对称加密的方式进行加密,以达到生成token与校验token方所用的盐不一致的安全效果!

非对称加密

基本原理:同时生成两把密钥:私钥和公钥,私钥隐秘保存,公钥可以下发给信任客户端

  • 私钥加密,持有公钥才可以解密
  • 公钥加密,持有私钥才可解密

优点:安全,难以破解

缺点:算法比较耗时,为了安全,可以接受

缩写:RSA。

对称加密算法不能实现签名,因此签名只能非对称算法

实战

具体的代码可以看githubspring-security-demo

引入依赖

我们首先开始实现JWT的创建,新建一个项目,引入依赖

Springboot:2.4.3

SpringSecurity:5.4.5

Mybatis-plus:3.4.3

mysql-connector:8.0.25

<?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 http://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.4.3</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <groupId>com.cupricnitrate</groupId>
    <artifactId>spring-security-demo</artifactId>
    <version>1.0.0</version>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>

        <jjwt.version>0.11.2</jjwt.version>

        <mybatis.plus.version>3.4.3</mybatis.plus.version>
        <mysql.version>8.0.25</mysql.version>
    </properties>

    <dependencies>

        <!--SpringSecurity-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <!--SpringBootWeb-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!--lombok-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!--参数验证-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <!--ORM-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybatis.plus.version}</version>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>${mysql.version}</version>
        </dependency>

        <!--jwt相关依赖-->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>${jjwt.version}</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>

        <!--自定义配置属性自动补全-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

</project>

创建JWT

上文说到,JWT中有一个荷载(payload)部分,其包含了有效信息,比如用户名,用户角色,过期时间等。这里定义一个荷载实体类

/**
 * 荷载类
 * 为了方便后期获取token中的用户信息,将token中载荷部分单独封装成一个对象
 * @author 硝酸铜
 * @date 2021/9/22
 */
@Data
public class Payload<T> {
    private String id;
    private T userInfo;
    private Date expiration;
    private Date issuedAt;
}

/**
 * 荷载中的数据
 * @author 硝酸铜
 * @date 2021/9/22
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class ClaimInfo {
    /**
     * 用户名
     */
    private String username;

    /**
     * 权限
     */
    private List<ClaimAuthority> authorities;

    @Data
    public static class ClaimAuthority implements GrantedAuthority{

        private String authority;

        @Override
        public String getAuthority() {
            return this.authority;
        }
    }
}

这里的ClaimAuthority类就是权限,其需要实现SpringSecurity的接口GrantedAuthority,SpringSecurity就是通过getAuthority()来读取权限的

JWT的签名部分,推荐使用非对称加密的形式,非对称加密的形式更为安全,当然在安全性要求没有这么高的情况下,不使用非对称加密也是可以的。

这里实现一下非对称加密的工具类,便于生成JWT

package com.cupricnitrate.util;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.security.*;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

/**
 * RSA工具类
 *
 * @author 硝酸铜
 * @date 2021/9/22
 */
public class RsaUtils {
    private static final int DEFAULT_KEY_SIZE = 2048;

    /**
     * 从文件中读取公钥
     *
     * @param filename 公钥保存路径
     * @return 公钥对象
     * @throws Exception
     */
    public static PublicKey getPublicKey(String filename) throws Exception {
        byte[] bytes = readFile(filename);
        return getPublicKey(bytes);
    }

    /**
     * 从文件中读取密钥
     *
     * @param filename 私钥保存路径
     * @return 私钥对象
     * @throws Exception
     */
    public static PrivateKey getPrivateKey(String filename) throws Exception {
        byte[] bytes = readFile(filename);
        return getPrivateKey(bytes);
    }

    /**
     * 获取公钥
     *
     * @param bytes 公钥的字节形式
     * @return
     * @throws Exception
     */
    private static PublicKey getPublicKey(byte[] bytes) throws Exception {
        bytes = Base64.getDecoder().decode(bytes);
        X509EncodedKeySpec spec = new X509EncodedKeySpec(bytes);
        KeyFactory factory = KeyFactory.getInstance("RSA");
        return factory.generatePublic(spec);
    }

    /**
     * 获取密钥
     *
     * @param bytes 私钥的字节形式
     * @return
     * @throws Exception
     */
    private static PrivateKey getPrivateKey(byte[] bytes) throws NoSuchAlgorithmException,
            InvalidKeySpecException {
        bytes = Base64.getDecoder().decode(bytes);
        PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(bytes);
        KeyFactory factory = KeyFactory.getInstance("RSA");
        return factory.generatePrivate(spec);
    }

    /**
     * 根据密文,生成rsa公钥和私钥,并写入指定文件
     *
     * @param publicKeyFilename  公钥文件绝对路径,比如:xxx/xxx/rsa_key.pub
     * @param privateKeyFilename 私钥文件绝对路径,比如:xxx/xxx/rsa_key
     * @param secret             生成密钥的密文
     */
    public static void generateKey(String publicKeyFilename, String privateKeyFilename, String
            secret, int keySize) throws Exception {
        KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
        SecureRandom secureRandom = new SecureRandom(secret.getBytes());
        keyPairGenerator.initialize(Math.max(keySize, DEFAULT_KEY_SIZE), secureRandom);
        KeyPair keyPair = keyPairGenerator.genKeyPair();
        // 获取公钥并写出
        byte[] publicKeyBytes = keyPair.getPublic().getEncoded();
        publicKeyBytes = Base64.getEncoder().encode(publicKeyBytes);
        writeFile(publicKeyFilename, publicKeyBytes);
        // 获取私钥并写出
        byte[] privateKeyBytes = keyPair.getPrivate().getEncoded();
        privateKeyBytes = Base64.getEncoder().encode(privateKeyBytes);
        writeFile(privateKeyFilename, privateKeyBytes);
    }
    public static void generateKey(String publicKeyFilename, String privateKeyFilename, String
            secret) throws Exception {
        generateKey(publicKeyFilename,privateKeyFilename,secret,DEFAULT_KEY_SIZE);
    }

    private static byte[] readFile(String fileName) throws Exception {
        return Files.readAllBytes(new File(fileName).toPath());
    }

    private static void writeFile(String destPath, byte[] bytes) throws IOException {
        File dest = new File(destPath);
        File dir = dest.getParentFile();
        //判断目录是否存在,不在则新建
        if(!dir.exists()){
            dir.mkdirs();
        }
        //判断文件是否存在,不在则新建
        if (!dest.exists()) {
            dest.createNewFile();
        }
        Files.write(dest.toPath(), bytes);
    }
}

有了这个工具类,就可以获取公钥,私钥了

接下来,实现一个生成JWT的工具类

package com.cupricnitrate.util;

import com.cupricnitrate.model.Authority;
import com.cupricnitrate.model.ClaimInfo;
import com.cupricnitrate.model.Payload;
import com.cupricnitrate.model.User;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException;

import java.security.Key;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.*;

/**
 * 生成token以及校验token相关方法
 *
 * @author 硝酸铜
 * @date 2021/9/22
 */
public class JwtUtils {

    private static final String JWT_PAYLOAD_USER_KEY = "user";

    // 用于HS512加密 签名的key
    public static final Key key = Keys.secretKeyFor(SignatureAlgorithm.HS512);


    /**
     * 私钥加密token
     *
     * @param claimInfo 载荷中的数据
     * @param key       key
     * @param expire    过期时间,单位ms
     * @return JWT
     */
    public static String generateTokenExpire(Object claimInfo,
                                             Key key,
                                             long expire,
                                             String id) {
        long now = System.currentTimeMillis();

        return Jwts.builder()
                .claim(JWT_PAYLOAD_USER_KEY, claimInfo)
                .setId(id)
                .setExpiration(new Date(now + expire))
                .setIssuedAt(new Date(now))
                //RS256加密
                .signWith(key, SignatureAlgorithm.RS256)
                //如果使用HS512加密则使用这个
                //.signWith(key, SignatureAlgorithm.HS512).compact();
                .compact();
    }

    /**
     * 解析token
     *
     * @param token 用户请求中的token
     * @param key   key
     * @return Jws<Claims>
     */
    public static Jws<Claims> parserToken(String token, Key key) {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
    }

    public static String createJTI() {
        return new String(Base64.getEncoder().encode(UUID.randomUUID().toString().getBytes()));
    }

    /**
     * 获取token中的用户信息
     *
     * @param token 用户请求中的令牌
     * @param key   key
     * @return 用户信息
     */
    public static <T> Payload<T> getInfoFromToken(String token, Key key, Class<T> userType) {
        Jws<Claims> claimsJws = parserToken(token, key);
        Claims body = claimsJws.getBody();
        Payload<T> claims = new Payload<>();
        claims.setId(body.getId());
        ObjectMapper objectMapper = new ObjectMapper();
        claims.setUserInfo(objectMapper.convertValue(body.get(JWT_PAYLOAD_USER_KEY),userType));
        claims.setExpiration(body.getExpiration());
        claims.setIssuedAt(body.getIssuedAt());
        return claims;
    }

    /**
     * 获取token中的载荷信息
     *
     * @param token 用户请求中的令牌
     * @param key   key
     * @return 用户信息
     */
    public static <T> Payload<T> getInfoFromToken(String token, Key key) {
        Jws<Claims> claimsJws = parserToken(token, key);
        Claims body = claimsJws.getBody();
        Payload<T> claims = new Payload<>();
        claims.setId(body.getId());
        claims.setExpiration(body.getExpiration());
        claims.setIssuedAt(body.getIssuedAt());
        return claims;
    }

    /**
     * 验证 token,忽略过期
     *
     * @param jwtToken token
     * @param key      key
     * @return boolean
     */
    public static boolean validateWithoutExpiration(String jwtToken, Key key) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(jwtToken);
            return true;
        } catch (ExpiredJwtException | SignatureException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
            if (e instanceof ExpiredJwtException) {
                return true;
            }
        }
        return false;
    }

    /**
     * 验证token
     *
     * @param jwtToken token
     * @param key      key
     * @return boolean
     */
    public static boolean validateToken(String jwtToken, Key key) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(jwtToken);
            return true;
        } catch (ExpiredJwtException | SignatureException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
            return false;
        }
    }
}

写一个main方法测试一下

public static void main(String[] args) throws Exception {
        //生成访问令牌公钥和私钥文件
        String keyPublicFilePath = "/Users/xiaoshengpeng/auth_key/key/rsa_key.pub";
        String keyPrivateFilePath = "/Users/xiaoshengpeng/auth_key/key/rsa_key";
        //RsaUtils.generateKey(keyPublicFilePath, keyPrivateFilePath, "CupricNitrate Key Token");

        //生成刷新令牌公钥和私钥文件
        String refreshPublicFilePath = "/Users/xiaoshengpeng/auth_key/refresh/rsa_key.pub";
        String refreshPrivateFilePath = "/Users/xiaoshengpeng/auth_key/refresh/rsa_key";
        //RsaUtils.generateKey(refreshPublicFilePath, refreshPrivateFilePath, "CupricNitrate Refresh Token");

        //模拟加密生成token
        PublicKey publicKey = RsaUtils.getPublicKey(keyPublicFilePath);
        PrivateKey privateKey = RsaUtils.getPrivateKey(keyPrivateFilePath);

        //权限设置
        List<ClaimInfo.ClaimAuthority> authorities = new ArrayList<>();
        ClaimInfo.ClaimAuthority authority = new ClaimInfo.ClaimAuthority();
        authority.setAuthority("ROLE_USER");
        authorities.add(authority);
        //荷载数据
        ClaimInfo claimInfo = ClaimInfo.builder()
                .username("user")
                .authorities(authorities)
                .build();

        //生成token
        String token = JwtUtils.generateTokenExpire(claimInfo, privateKey, 24 * 60 * 60 * 1000, createJTI());

        System.out.println("token: " + token);

        //模拟解密从token中获取用户信息
        ObjectMapper objectMapper = new ObjectMapper();
        //序列化时忽略值为null的属性
        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);

        Payload<User> payload = JwtUtils.getInfoFromToken(token,
                publicKey, User.class);
        User user1 = payload.getUserInfo();
        System.out.println("user: " + objectMapper.writeValueAsString(user1));
    }

控制台输出:

token: eyJhbGciOiJSUzI1NiJ9.eyJ1c2VyIjp7InVzZXJuYW1lIjoidXNlciIsImF1dGhvcml0aWVzIjpbeyJhdXRob3JpdHkiOiJST0xFX1VTRVIifV19LCJqdGkiOiJPV1F6Tnpoa01HWXRNbVpoWWkwME9HUTRMV0ptTWpVdFl6TTVPRGczT1dVMk1XRTMiLCJleHAiOjE2MzI0NDk3NDgsImlhdCI6MTYzMjM2MzM0OH0.Isga_gn8JcskbFsIrzYWuzB-oLusSD7kUM6_gEr8TP5y7RrB0eS2l7YoNCox0xsdVBkf2ANn-zwqYvlqy3bDFCVgbNiNjUiZ3YiD0MJliR2J1Ci2sg0Tjt98EnxkzaS07rg9kfhrGhZ0vdb_EwMVmms1o6-a-gj1baOFsF3_ZmBL_rR4lDDPY5R86SUTJdVksZTBsnxv4bmqV15asnaJg8_f6mYuCH-dMeJ836G_EFgGu4XsqC_n5Fkm9fxkwzpzLdTlAXawgdiPeNhxwn_8bHcWhnP0m62L1pQj39mj15ghISAUs0EWlFP6DKNddCAQf3gDjfVPt5f-CSTv0JD04Q
user: {"username":"user","authorities":[{"authority":"ROLE_USER"}],"accountNonExpired":true,"accountNonLocked":true,"credentialsNonExpired":true,"enabled":true}

使用这个token在jwt.io上解析:

这里可以看到JWT是可以通过被解析出来的,其非对称加密只是将签名进行加密,在验签的时候起到安全防护的作用。所以不要在JWT中存放敏感信息!

访问令牌和刷新令牌

上文的main方法中,我生成了两套公私钥,访问令牌公私钥和刷新令牌公私钥,为什么需要两个令牌呢?我使用一个令牌不就可以了吗?当然可以,但是使用两个令牌更为安全!

令牌一旦签发出去之后,就默认已经暴露出去了,因为不论我们保存在客户端还是服务器中,别人想要获取这个令牌,是很容易的。

所以,对于公开的令牌,我们采用的策略就是减少它的生命周期,比如说5分钟有效,这样别人拿到令牌之后,这个令牌可能已经过期了,令牌泄漏了也不会造成太大的影响。

还有就是在服务端做一些异常检查,因为一个安全系统,是不能单纯的依赖一个单一的手段的,比如还需要在服务端中检查高频访问的IP这些东西,做一些限制。综合来判断是否有人盗取了用户的信息,防止恶意用户的攻击。

  • 访问令牌(直接开门的钥匙):声明周期要短,一般在几分钟到几小时之间,防止令牌暴露之后,黑客为所欲为
  • 刷新令牌(不能直接开门,是用来生成新的访问令牌用的):生命周期会长,应该在几周到不超过一年
  • 上面两种令牌使用不同的key签发
  • 当访问令牌过期之后,使用刷新令牌去生成新的访问令牌。由于生成新的访问令牌需要同时验证刷新令牌和过期的访问令牌,所以就降低了令牌暴露的危险。

创建JWT过滤器

SpringSecurityOauth2有内建的JwtFilter,并且支持得很好

但是SpringSecurity里面没有单体应用的JwtFilter,需要我们自己实现

  • 认证成功就是把Authentication对象setAuthenticated(true),然后存到SecurityContext中。
  • 认证失败就是清空SecurityContext然后交给下一个Filter处理

首先自定义一个属性,便于token相关的配置

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/**
 * token配置属性类
 * @author 硝酸铜
 * @date 2021/9/22
 */
@Data
@Component
@ConfigurationProperties(prefix = TokenPropertities.PREFIX)
public class TokenProperties {
    public static final String PREFIX = "token";

    /**
     * Http报头中令牌自定义标识,默认:Authorization
     */
    private String header = "Authorization";

    /**
     * Http报头中令牌自定义标识中的开头,默认:Bearer
     */
    private String prefix = "Bearer";

    /**
     * 访问令牌相关属性
     */
    private AccessToken access;

    /**
     * 刷新令牌相关属性
     */
    private RefreshToken refresh;


    @Data
    public static class AccessToken{
        /**
         * 访问令牌过期时间,单位ms,默认60s
         */
        private Long expireTime = 60 * 1000L;

        /**
         * 访问令牌私钥文件访问路径,比如/user/auth_key/rsa_key
         */
        private String privateKey;

        /**
         * 访问令牌公钥文件访问路径,比如/user/auth_key/rsa_key.pub
         */
        private String publicKey;

    }

    @Data
    public static class RefreshToken{
        /**
         * 刷新令牌过期时间,单位ms,默认30天
         */
        private Long expireTime = 30 * 24 * 60 * 60 * 1000L;

        /**
         * 访问令牌私钥文件访问路径,比如/user/auth_key/rsa_key
         */
        private String privateKey;

        /**
         * 访问令牌公钥文件访问路径,比如/user/auth_key/rsa_key.pub
         */
        private String publicKey;
    }
}

然后在application.yaml中将其配置好

spring:
  datasource:
    type: com.zaxxer.hikari.HikariDataSource
    #mysql驱动8.x版本使用com.mysql.cj.jdbc.Driver
    #5.x使用com.mysql.jdbc.Driver
    driver-class-name: com.mysql.cj.jdbc.Driver
    #数据库地址
    url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=UTC
    #数据库账号
    username: root
    #数据库密码
    password: root
    #hikari连接池
    hikari:
      #2*cpu
      maximum-pool-size: 16
      #cpu
      minimum-idle: 8
      data-source-properties:
        cachePrepStmts: true
        prepStmtCacheSize: 250
        prepStmtCacheSqlLimit: 2048
        useServerPrepStmts: true

token:
  access:
    public-key: /Users/xiaoshengpeng/auth_key/key/rsa_key.pub
    private-key: /Users/xiaoshengpeng/auth_key/key/rsa_key
  refresh:
    public-key: /Users/xiaoshengpeng/auth_key/refresh/rsa_key.pub
    private-key: /Users/xiaoshengpeng/auth_key/refresh/rsa_key

接下来我们来实现签发token的步骤。

一般我们我们将签发token的步骤写在登录中

还记得我们怎么自定义认证逻辑的吗?

创建一个Jwt认证过滤器,使其继承UsernamePasswordAuthenticationFilter

import com.cupricnitrate.config.property.TokenProperties;
import com.cupricnitrate.http.req.LoginReqDto;
import com.cupricnitrate.http.resp.LoginRespDto;
import com.cupricnitrate.model.Authority;
import com.cupricnitrate.model.ClaimInfo;
import com.cupricnitrate.util.JwtUtils;
import com.cupricnitrate.util.RsaUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
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.io.InputStream;
import java.io.PrintWriter;
import java.security.PrivateKey;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
 * Jwt认证过滤器
 * @author 硝酸铜
 * @date 2021/9/22
 */
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;

    private final TokenProperties tokenProperties;

    public JwtAuthenticationFilter(AuthenticationManager authenticationManager,TokenProperties tokenProperties) {
        this.authenticationManager = authenticationManager;
        this.tokenProperties = tokenProperties;
        // 浏览器访问 /authorize/login 会通过 JWTAuthenticationFilter
        setFilterProcessesUrl("/authorize/login");
    }

    /**
     * json格式:
     *
     * {
     *     "username": "user",
     *     "password": "12345678"
     * }
     *
     * @param request 请求体
     * @param response 返回体
     * @return Authentication
     * @throws AuthenticationException 认证异常
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        InputStream is = null;
        LoginReqDto req = null;
        try {
            //从Body中读取参数
            is = request.getInputStream();
            //使用jackson解析json
            ObjectMapper objectMapper = new ObjectMapper();
            req = objectMapper.readValue(is,LoginReqDto.class);
        } catch (IOException e) {
            e.printStackTrace();
            throw new BadCredentialsException("json格式错误,没有找到用户名或密码");
        }

        //认证,同父类,生成一个没有被完全初始化的Authentication
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(req.getUsername(), req.getPassword());
        this.setDetails(request, authRequest);
        return authenticationManager.authenticate(authRequest);
    }


    /**
     * 认证成功逻辑
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        //令牌私钥
        PrivateKey accessPrivateKey = null;
        PrivateKey refreshPrivateKey = null;
        try {
            accessPrivateKey = RsaUtils.getPrivateKey(tokenProperties.getAccess().getPrivateKey());
            refreshPrivateKey = RsaUtils.getPrivateKey(tokenProperties.getRefresh().getPrivateKey());
        } catch (Exception e) {
            e.printStackTrace();
        }

        //创建荷载信息
        List<ClaimInfo.ClaimAuthority> authorities = authResult.getAuthorities().stream().map(a -> {
            ClaimInfo.ClaimAuthority claimAuthority = new ClaimInfo.ClaimAuthority();
            claimAuthority.setAuthority(a.getAuthority());
            return claimAuthority;
        }).collect(Collectors.toList());

        ClaimInfo claim = ClaimInfo.builder().username(authResult.getName()).authorities(authorities).build();
        //签发token,使用私钥进行签发
        LoginRespDto respDto = new LoginRespDto(
                JwtUtils.generateTokenExpire(claim, accessPrivateKey,tokenProperties.getAccess().getExpireTime(), JwtUtils.createJTI()),
                JwtUtils.generateTokenExpire(claim, refreshPrivateKey,tokenProperties.getRefresh().getExpireTime(),JwtUtils.createJTI()));

        try {
            //登录成功時,返回json格式进行提示
            response.setContentType("application/json;charset=utf-8");
            response.setStatus(HttpServletResponse.SC_OK);
            PrintWriter out = response.getWriter();
            Map<String, Object> map = new HashMap<>();
            map.put("code", HttpServletResponse.SC_OK);
            map.put("message", "登陆成功!");
            map.put("token",respDto);
            out.write(new ObjectMapper().writeValueAsString(map));
            out.flush();
            out.close();
        } catch (Exception e1) {
            e1.printStackTrace();
        }
    }

    /**
     * 登录失败逻辑
     */
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        try {
            //登录成功時,返回json格式进行提示
            response.setContentType("application/json;charset=utf-8");
            response.setStatus(HttpServletResponse.SC_OK);
            PrintWriter out = response.getWriter();
            Map<String, Object> map = new HashMap<>();
            map.put("code", HttpServletResponse.SC_FORBIDDEN);
            map.put("message", "登陆失败!");
            map.put("reason",failed.getMessage());
            out.write(new ObjectMapper().writeValueAsString(map));
            out.flush();
            out.close();
        } catch (Exception e1) {
            e1.printStackTrace();
        }
    }
}

具体逻辑可以看注释,很清晰了

完成了认证逻辑,还需要实现一个验证token的过滤器,客户端在登陆后,请求接口的时候,在Http Header中带上Authorization:Bearer XXX(xxx是JWT)即可通过认证

import com.cupricnitrate.config.property.TokenProperties;
import com.cupricnitrate.model.ClaimInfo;
import com.cupricnitrate.model.Payload;
import com.cupricnitrate.util.JwtUtils;
import com.cupricnitrate.util.RsaUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
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.io.PrintWriter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * @author 硝酸铜
 * @date 2021/9/22
 */
public class JwtVerifyFilter extends BasicAuthenticationFilter {

    private TokenProperties tokenProperties;

    public JwtVerifyFilter(AuthenticationManager authenticationManager, TokenProperties tokenProperties) {
        super(authenticationManager);
        this.tokenProperties = tokenProperties;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        if (checkJwtToken(request)) {
            try {
                //获取权限失败,会抛出异常
                UsernamePasswordAuthenticationToken authentication = getAuthentication(request);
                //获取后,将Authentication写入SecurityContextHolder中供后序使用
                SecurityContextHolder.getContext().setAuthentication(authentication);
                chain.doFilter(request, response);
            } catch (Exception e) {
                responseJson(response);
                e.printStackTrace();
            }
        } else {
            //token不在请求头中,则说明是匿名用户访问
            List<GrantedAuthority> list = new ArrayList<>();
            GrantedAuthority grantedAuthority = () -> "ROLE_ANONYMOUS";
            list.add(grantedAuthority);
            AnonymousAuthenticationToken authentication = new AnonymousAuthenticationToken("token-anonymousUser","anonymousUser", list);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            chain.doFilter(request, response);
        }
    }


    /**
     * 检查JWT Token 是否在HTTP 报头中
     *
     * @param request HTTP请求
     * @return boolean
     */
    private boolean checkJwtToken(HttpServletRequest request) {
        String header = request.getHeader(tokenProperties.getHeader());
        return header != null && header.startsWith(tokenProperties.getPrefix());
    }

    /**
     * 未登录提示
     *
     * @param response
     */
    private void responseJson(HttpServletResponse response) {
        try {
            //未登录提示
            response.setContentType("application/json;charset=utf-8");
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            PrintWriter out = response.getWriter();
            Map<String, Object> map = new HashMap<String, Object>();
            map.put("code", HttpServletResponse.SC_FORBIDDEN);
            map.put("message", "未登录或登录过期,请进行登录!");
            out.write(new ObjectMapper().writeValueAsString(map));
            out.flush();
            out.close();
        } catch (Exception e1) {
            e1.printStackTrace();
        }
    }

    /**
     * 通过token,获取用户信息
     *
     * @param request
     * @return
     */
    @SneakyThrows
    private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
        //读取请求头中的Authorization的值
        String token = request.getHeader("Authorization");
        if (token != null) {
            //Authorization 中JWT传参,默认格式 Authorization:Bearer XXX
            token = token.replaceFirst(tokenProperties.getPrefix(), "");
            //通过token解析出载荷信息,使用公钥进行解析
            Payload<ClaimInfo> payload = JwtUtils.getInfoFromToken(token, RsaUtils.getPublicKey(tokenProperties.getAccess().getPublicKey()), ClaimInfo.class);
            ClaimInfo claimInfo = payload.getUserInfo();
            //不为null,返回一个完全初始化的Authentication
            if (claimInfo != null) {
                return new UsernamePasswordAuthenticationToken(claimInfo.getUsername(), null, claimInfo.getAuthorities());
            }
            return null;

        }
        return null;
    }
}

最后,将这两个安全过滤器设置到SpringSecurity的安全配置中即可

/**
 * @author 硝酸铜
 * @date 2021/9/22
 */
@EnableWebSecurity(debug = true)
@Slf4j
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Resource
    private TokenProperties tokenProperties;
    ...

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //禁用生成默认的登陆页面
                .formLogin(AbstractHttpConfigurer::disable)
                //关闭httpBasic,采用自定义过滤器
                .httpBasic(AbstractHttpConfigurer::disable)
                //前后端分离架构不需要csrf保护,这里关闭
                .csrf(AbstractHttpConfigurer::disable)
                //禁用生成默认的注销页面
                .logout(AbstractHttpConfigurer::disable)
                .authorizeRequests(req -> req
                        //允许访问authorize url下的所有接口
                        .antMatchers("/authorize/**").permitAll()
                        .anyRequest().authenticated()
                )
                //添加我们自定义的过滤器,替代UsernamePasswordAuthenticationFilter
                .addFilterAt(new JwtAuthenticationFilter(authenticationManager(),tokenProperties), UsernamePasswordAuthenticationFilter.class)
                //添加token检验过滤器
                .addFilter(new JwtVerifyFilter(authenticationManager(),tokenProperties))
                //前后端分离是无状态的,不用session了,直接禁用。
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }
		...
}

刷新令牌接口

登陆是获取令牌的接口,那么访问令牌过期了,需要使用刷新令牌去刷新令牌

我们接下来实现一下刷新令牌的接口

/**
 * @author 硝酸铜
 * @date 2021/9/22
 */
@RestController
@RequestMapping("/authorize")
public class AuthorizeController {
  ...
    /**
     * 刷新访问令牌
     * @param req 请求体
     * @param authorization 访问令牌
     * @return LoginRespDto
     */
    @PostMapping(value = "/refreshToken")
    public LoginRespDto refreshToken(@Validated @RequestBody RefreshTokenReqDto req, @RequestHeader(name = "Authorization") String authorization){
        return tokenService.refreshToken(authorization.replaceFirst("Bearer ", ""),req.getRefreshToken());
    }
  ...
}


/**
 * @author 硝酸铜
 * @date 2021/9/22
 */
@Data
public class RefreshTokenReqDto implements Serializable {
    private static final long serialVersionUID = 8410311036049755024L;

    /**
     * 刷新令牌
     */
    @NotBlank
    private String refreshToken;
}
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Optional;

/**
 * @author 硝酸铜
 * @date 2021/9/22
 */
@Service
public class TokenService {

    @Resource
    private TokenProperties tokenProperties;

    /**
     * 使用刷新token创建访问token
     * @param token 访问token
     * @param refreshToken 刷新token
     * @return 访问token
     */
    public LoginRespDto refreshToken(String token, String refreshToken){
        LoginRespDto resp = new LoginRespDto();
        //获取公钥和私钥
        PublicKey accessPublicKey = null;
        PrivateKey accessPrivateKey = null;
        PublicKey refreshPublicKey = null;
        PrivateKey refreshPrivateKey = null;
        try {
            //访问令牌公钥
            accessPublicKey = RsaUtils.getPublicKey(tokenProperties.getAccess().getPublicKey());
            //访问令牌私钥
            accessPrivateKey = RsaUtils.getPrivateKey(tokenProperties.getAccess().getPrivateKey());
            //刷新令牌公钥
            refreshPublicKey = RsaUtils.getPublicKey(tokenProperties.getRefresh().getPublicKey());
        } catch (Exception e) {
            e.printStackTrace();
        }

        //解析刷新令牌并生成新的访问令牌
        if(JwtUtils.validateWithoutExpiration(token,accessPublicKey) &&
                JwtUtils.validateToken(refreshToken,refreshPublicKey)){
            PrivateKey key = accessPrivateKey;

            //生成新的访问令牌
            String accessToken = Optional.ofNullable(JwtUtils.parserToken(refreshToken, refreshPublicKey))
                    .map(claims ->
                            JwtUtils.generateTokenExpire(claims.getBody(),
                                    key,
                                    tokenProperties.getAccess().getExpireTime(),
                                    JwtUtils.createJTI()))
                    .orElseThrow(() -> new AccessDeniedException("访问被拒绝"));

            resp.setAccessToken(accessToken);
        }
        return resp;
    }
}

调用接口

我们来验证一下,先进行登陆:

这个时候访问接口是可以通过认证的

等待一定时间,访问令牌过期

这个时候,调用刷新令牌接口,生成新的访问令牌

使用新的访问令牌,又能通过认证了

到此为止,关于认证的事情我们就做完了

接下来我们来学习一下授权的事情

posted @ 2021-09-27 16:39  硝酸铜  阅读(1744)  评论(0编辑  收藏  举报