Spring+Security+JWT+MyBatisPlus

Posted 白玉梁

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Spring+Security+JWT+MyBatisPlus相关的知识,希望对你有一定的参考价值。

Web的认证和授权,是老生常谈的问题,常见的几种认证授权模式:

1.cookies+session:

用户在浏览器中登录成功后,服务器生成sessionId并返回给浏览器,同时服务端存储session,浏览器将sessionId保存至cookies,下次发起请求时带上,服务器将通过传入的sessionId获取对应的session信息,并通过session来确认用户登录信息;

2.token:令牌
这是我们在写移动端app时最常见的认证方式,即用户登录成功后,服务端根据一定规则生成token串返回给移动端,同时服务端将该token与该用户对应存储,移动端保存该token并在以后的接口请求都会带上;
token也有多种实现方式,比如token+sign方式,或者Oauth2(refresh_token+access_token)方式等;

3.JWT:Java Web Token
严格来说,这是一种设计思想,前两种都是将用户认证信息存储在服务端,缺点就是占用资源,共享,跨域等方面的问题,而JWT则是向token存储在前端,服务器不做任何存储,只做验证;具体可以查看JWT官网说明:https://jwt.io/introduction,或者百度,会有很多专业的讲解,我这里只讲一下大致流程:
用户登录后,服务器根据jwt规则,将用户信息结合自定义密钥(此密钥只有服务器知道,不可泄漏)通过加密算法,生成token字符串(xxxxx.xxxxx.xxxxx),例:

eyJhbGciOiJIUzI1NiJ9
.eyJzdWIiOiI2NjY2NjYiLCJjcmVhdGVkIjoiU2VwIDE2LCAyMDIxIDE6MzM6MDggUE0iLCJleHAiOjE2MzIzNzUxODh9
.bVODaWOwweOXEF_Pi4fgHqU1FT4kdWXKtxLmhaqCdY4

即Header.Payload.Signature,返回给前端,前端保存该token,并在以后的请求中带上:

Authorization: Bearer xxxxx.xxxxx.xxxxx

服务器接收到请求后,通过密钥将token中的用户及创建时间信息解析出来,以此来验证token的有效期以及请求的合法性;

JWT是目前最流行的跨域认证解决方案,但缺点也尤其明显

1.因为服务端不存储token,就无法控制token的有效性,若token被劫持,服务器就算知道也只能默默流泪毫无作为,直到token过期;

2.无法做到单客户端登录,也就是说多设备可以同时登录,无法完成互踢操作,除非服务器记录用户登录信息,但这又违背了JWT方案中服务器不保存任何信息的设计思想,所以如果要实现这种需求,就只能依靠外力,比如IM,依靠IM服务器的互踢功能来达到整个应用的互踢功能;

总结:至于实际开发中到底选择哪种认证方式,还要结合实际需求来做判断!

GitHub:https://github.com/baiyuliang/SpringBootJwt

好了,下面贴出本博客所实现的SpringBoot+Security+Jwt+MybatisPlus的案例截图:

项目结构:

数据库:

Sql:

create table jwttest.permissions
(
    id          int auto_increment
        primary key,
    path        varchar(255)             null,
    role_ids    varchar(255) default '1' null,
    description varchar(255)             null
);

create table jwttest.role
(
    id          int          not null,
    name        varchar(255) null,
    description varchar(255) null,
    constraint role_id_uindex
        unique (id)
);

alter table jwttest.role
    add primary key (id);

create table jwttest.user
(
    id       bigint        not null comment '主键ID'
        primary key,
    username varchar(255)  null,
    password varchar(255)  null,
    nickname varchar(30)   null comment '姓名',
    age      int           null comment '年龄',
    email    varchar(50)   null comment '邮箱',
    status   int default 1 null,
    role_id  int default 1 null
);


请求示例(测试JWT):


请求示例(测试权限):


首先引入相关依赖:

mysql、mybatis-plus、mybatis-plus-generator、lombok、p6spy、security、jwt、hutool

        <!--mysql数据库-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <!--mybatis-plus-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.4.3.3</version>
        </dependency>
        <!--lombok-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <!--mybatis-plus-generator-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-generator</artifactId>
            <version>3.5.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.velocity</groupId>
            <artifactId>velocity-engine-core</artifactId>
            <version>2.3</version>
        </dependency>
        <!--sql打印-->
        <dependency>
            <groupId>p6spy</groupId>
            <artifactId>p6spy</artifactId>
            <version>3.9.1</version>
        </dependency>

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

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>0.11.2</version>
        </dependency>

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>0.11.2</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-gson</artifactId>
            <version>0.11.2</version>
        </dependency>

        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>4.5.15</version>
        </dependency>

application.yml:

spring:
  application:
    name: jwttest
  datasource:
    username: root
    password: root
    #    url: jdbc:mysql://localhost:3306/jwttest?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Hongkong
    #    driver-class-name: com.mysql.cj.jdbc.Driver
    driver-class-name: com.p6spy.engine.spy.P6SpyDriver #sql语句打印
    url: jdbc:p6spy:mysql://localhost:3306/jwttest?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Hongkong

jwt:
  tokenType: 'Bearer '  #JWT负载开头
  tokenHeader: Authorization #JWT存储的请求头
  secret: cuAihCz53DZRjZwbsGcZJ2Ai6At+T142uphtJMsk7iQ= #JWT加解密密钥
  expiration: 604800 #JWT的超期限时间(60*60*24*7)

secure:
  ignored:
    urls: #路径白名单
      - /user/testNoJwt

使用P6SpyDriver 代替mysqlDriver,查询数据库时直接打印sql语句:

配置jwt的密钥和过期时间,以及白名单路径!

MybatisPlus使用相关可参考官方文档,非常简单,可通过项目中的CodeGenerator自动生成!

下面贴了部分关键类代码,不想看的话可以直接跳过看下面讲解的具体认证流程步骤!

Jwt工具类 JwtTokenUtil:

public class JwtTokenUtil {
    private static final Logger LOGGER = LoggerFactory.getLogger(JwtTokenUtil.class);
    private static final String CLAIM_KEY_USERNAME = "sub";
    private static final String CLAIM_KEY_CREATED = "created";
    @Value("${jwt.secret}")
    private String secret;
    @Value("${jwt.expiration}")
    private Long expiration;
    @Value("${jwt.tokenType}")
    private String tokenType;


    /**
     * 根据负责生成JWT的token
     */
    private String generateToken(Map<String, Object> claims) {
        return Jwts.builder()
                .setClaims(claims)
                .setExpiration(generateExpirationDate())
                .signWith(Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)))
                .compact();
    }

    /**
     * 从token中获取JWT中的负载
     */
    private Claims getClaimsFromToken(String token) {
        Claims claims = null;
        try {
            claims = Jwts.parserBuilder()
                    .setSigningKey(Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)))
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
        } catch (Exception e) {
            LOGGER.info("JWT格式验证失败:{}", token);
        }
        return claims;
    }

    /**
     * 生成token的过期时间
     */
    private Date generateExpirationDate() {
        return new Date(System.currentTimeMillis() + expiration * 1000);
    }

    /**
     * 从token中获取登录用户名
     */
    public String getUserNameFromToken(String token) {
        String username;
        try {
            Claims claims = getClaimsFromToken(token);
            username = claims.getSubject();
        } catch (Exception e) {
            username = null;
        }
        return username;
    }

    /**
     * 验证token是否还有效
     *
     * @param token       客户端传入的token
     * @param userDetails 从数据库中查询出来的用户信息
     */
    public boolean validateToken(String token, UserDetails userDetails) {
        String username = getUserNameFromToken(token);
        return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
    }

    /**
     * 判断token是否已经失效
     */
    private boolean isTokenExpired(String token) {
        Date expiredDate = getExpiredDateFromToken(token);
        return expiredDate.before(new Date());
    }

    /**
     * 从token中获取过期时间
     */
    private Date getExpiredDateFromToken(String token) {
        Claims claims = getClaimsFromToken(token);
        return claims.getExpiration();
    }

    /**
     * 根据用户信息生成token
     */
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        claims.put(CLAIM_KEY_USERNAME, userDetails.getUsername());
        claims.put(CLAIM_KEY_CREATED, new Date());
        return generateToken(claims);
    }

    /**
     * 当原来的token没过期时是可以刷新的
     *
     * @param oldToken 带tokenHead的token
     */
    public String refreshHeadToken(String oldToken) {
        if (StrUtil.isEmpty(oldToken)) {
            return null;
        }
        String token = oldToken.substring(tokenType.length());
        if (StrUtil.isEmpty(token)) {
            return null;
        }
        //token校验不通过
        Claims claims = getClaimsFromToken(token);
        if (claims == null) {
            return null;
        }
        //如果token已经过期,不支持刷新
        if (isTokenExpired(token)) {
            return null;
        }
        //如果token在30分钟之内刚刷新过,返回原token
        if (tokenRefreshJustBefore(token, 30 * 60)) {
            return token;
        } else {
            claims.put(CLAIM_KEY_CREATED, new Date());
            return generateToken(claims);
        }
    }

    /**
     * 判断token在指定时间内是否刚刚刷新过
     *
     * @param token 原token
     * @param time  指定时间(秒)
     */
    private boolean tokenRefreshJustBefore(String token, int time) {
        Claims claims = getClaimsFromToken(token);
        Date created = claims.get(CLAIM_KEY_CREATED, Date.class);
        Date refreshDate = new Date();
        //刷新时间在创建时间的指定时间内
        if (refreshDate.after(created) && refreshDate.before(DateUtil.offsetSecond(created, time))) {
            return true;
        }
        return false;
    }
}

配置SecurityConfig:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity.authorizeRequests();
        //不需要保护的资源路径允许访问
        for (String url : ignoreUrlsConfig().getUrls()) {
            System.out.println("白名单:" + url);
            registry.antMatchers(url).permitAll();
        }
        //允许跨域请求的OPTIONS请求
        registry.antMatchers(HttpMethod.OPTIONS).permitAll();
        // 任何请求需要身份认证
        registry.and()
                .authorizeRequests()
                .anyRequest()
                .authenticated()
                // 关闭跨站请求防护及不使用session
                .and()
                .csrf()
                .disable()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                // 自定义权限拒绝处理类
                .and()
                .exceptionHandling()
                .accessDeniedHandler(restfulAccessDeniedHandler())//授权
                .authenticationEntryPoint(restAuthenticationEntryPoint())//认证
                // 自定义权限拦截器JWT过滤器
                .and()
                .addFilterBefore(jwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class);
        //动态权限校验过滤器
        registry.and().addFilterBefore(securityFilter(), FilterSecurityInterceptor.class);
    }

    /**
     * 白名单
     *
     * @return
     */
    @Bean
    public IgnoreUrlsConfig ignoreUrlsConfig() {
        return new IgnoreUrlsConfig();
    }

    /**
     * 自定义无权限返回结果
     *
     * @return
     */
    @Bean
    public JwtAccessDeniedHandler restfulAccessDeniedHandler() {
        return new JwtAccessDeniedHandler();
    }

    /**
     * 自定义未登录或登录过期返回结果
     *
     * @return
     */
    @Bean
    public JwtAuthenticationEntryPoint restAuthenticationEntryPoint() {
        return new JwtAuthenticationEntryPoint();
    }

    /**
     * JWT登录授权过滤器(对请求进行授权验证过滤)
     *
     * @return
     */
    @Bean
    public 

以上是关于Spring+Security+JWT+MyBatisPlus的主要内容,如果未能解决你的问题,请参考以下文章

单点登录JWT与Spring Security OAuth

带有 spring-boot 和 spring-security 的 JWT

Spring Security OAuth2 v5:NoSuchBeanDefinitionException:'org.springframework.security.oauth2.jwt.Jwt

Spring Security+JWT简述

spring boot + spring security + jwt + React 不工作

Spring Boot + Security + JWT 无法生成令牌