SpringSecurity学习三 SpringSecurity 结合jwt

上一篇::SpringSecurity学习二 SpringSecurity部署数据库


SpringSecurity 结合jwt

前面文章搭配数据库实现了SpringSecurity的基本使用,默认使用的还是cookie/session,现在改为jwt认证。实际中根据需求不一定用到jwt,但是学习改造成jwt很能加深对SpringSecurity的理解。

项目源码gitte地址: https://gitee.com/xiang_Gitee/spring-security-learn(子工程token)


JWT

简单介绍

  • JWT是Json web token的缩写,Json Web Token, 是一种JSON风格的轻量级的授权和身份认证规范,可实现无状态、分布式的Web应用授权。
  • 简单来讲就是用户登录成功之后,服务器根据自己设定的密钥和jwt的规则生成一个凭证,并把这个凭证返回给用户,用户再访问时候附带上设个凭证,服务器根据自己的密钥和jwt规则解析这个凭证,判断凭证是否通过。

和传统验证的最大区别就在于服务器不需要存储任何数据(除了密钥),单纯通过字符串的编码、解码来进行认证,所以JWT是“无状态的”。

JWT数据格式

JWT包含三部分数据:

  1. Header:头部,通常头部有两部分信息:

    	声明类型,声明这是JWT类型
    	算法,使用的算法名称,用于生成第三部分签名
    

    对头部用Base64Url编码,得到第一部分数据

  2. Payload:载荷,就是有效数据,在官方文档中(RFC7519),这里给了7个示例信息:

    	iss (issuer):表示签发人
    	exp (expiration time):表示token过期时间
    	sub (subject):主题
    	aud (audience):受众
    	nbf (Not Before):生效时间
    	iat (Issued At):签发时间
    	jti (JWT ID):编号
    

    对这部分也用Base64Url编码,得到第二部分数据。

  3. Signature:签名,是整个数据的认证信息。
    一般根据前两步的数据,再加上服务的的密钥secret(密钥保存在服务端,不能泄露给客户端),通过Header中配置的加密算法生成Signature,用这个Signature验证整个数据完整和可靠性。

JWT 缺点

由于采用jwt服务器就不保存会话状态,所以

  1. 一旦JWT签发,在有效期内将会一直有效,不能取消,所以无法注销一个jwt,虽然可通过服务端修改secret的方法注销,但修改之前的jwt也会由于密钥不一致而全部失效。
  2. 传统的cookie+session的方案天然的支持续签,但是jwt的状态保存在本身,无法单纯通过后端得以续签。
  3. 密码更改前的jwt仍然可用,这倒是可以通过将secret设置成密码来解决。

JWT使用

以后端的角度,在用户登录成功的时候生成有效的JWT,在用户访问的时候验证前端附带的JWT是否有效,就这么简单,即 生成jwt、验证jwt


相关依赖

较之前加入了java-jwt

<!-- jwt依赖 -->
    <dependency>
      <groupId>com.auth0</groupId>
      <artifactId>java-jwt</artifactId>
      <version>3.16.0</version>
    </dependency>
<!-- 基本依赖 -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
      <version>RELEASE</version>
      <scope>compile</scope>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>

    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <version>${lombok.version}</version>
    </dependency>

    <!--数据库-->
    <dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
      <version>${mysql.version}</version>
    </dependency>
    <dependency>
      <groupId>com.alibaba</groupId>
      <artifactId>druid-spring-boot-starter</artifactId>
      <version>${druid.version}</version>
    </dependency>
    <dependency>
      <groupId>com.baomidou</groupId>
      <artifactId>mybatis-plus-boot-starter</artifactId>
      <version>${mybatisplus.version}</version>
    </dependency>

    <dependency>
      <groupId>com.github.pagehelper</groupId>
      <artifactId>pagehelper-spring-boot-starter</artifactId>
      <version>1.3.0</version>
    </dependency>

步骤

SpringSecurity的所有控制都是用过spring的Filter链实现的,也就是说所有配置主要就是通过在Filter链上加减替换Filter。
应用jwt所要进行的具体操作是:

  1. 禁用session。jwt是无状态的登录,依靠jwt本身携带的信息判断登录情况,这里先禁掉session排除干扰。
  2. 拦截登录请求,自定义实现登录过程,产生jwt返回给用户。
  3. 拦截所有请求,自定义验证过程,主要是检查请求附带的jwt。

按过滤器链的顺序来说,第3步拦截是在第2步拦截之前。
这里主要是按登录顺序来讲。


SecurityConfig 配置

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private UserService userService;
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf()//禁用跨域保护策略,默认开启
                .disable()
                .authorizeRequests()//认证需求路径
                .antMatchers("/", "/home")
                .permitAll()
                .anyRequest()
                .authenticated();
                
      //SpringSecurity中的Session管理有几种状态,默认`ifRequired`,表示有需要就创建Session。‘STATELESS’表示从不创建和使用Session。
      http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
      //在UsernamePasswordAuthenticationFilter这个过滤器之前添加自定义的认证拦截器TokenFilter,这个拦截器是自定义的
      http.addFilterBefore(new TokenFilter(),UsernamePasswordAuthenticationFilter.class);
      //用自定义的loginFilter代替UsernamePasswordAuthenticationFilter,源码注释说addFilterAt这个操作不会覆盖原来的过滤器,但实际似乎会?
      http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
    }

    @Bean
    LoginFilter loginFilter() throws Exception {
        LoginFilter loginFilter = new LoginFilter();
        loginFilter.setFilterProcessesUrl("/dosignin");
        loginFilter.setAuthenticationManager(authenticationManagerBean());
        return loginFilter;
    }
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //用自定义的userDetailsService,并设置一个无加密的加密器
        auth.userDetailsService(userService)
        .passwordEncoder(NoOpPasswordEncoder.getInstance());
    }

}

这里已经完成了第一步,禁用session。
在这里插入图片描述

需要注意的是在自定义登录认证之后SecurityConfig 里原来配置的loginProcessingUrl、failureUrl、defaultSuccessUrl就失效了,上面贴的SecurityConfig 也可以看到这几句配置已经删去。登录成功/失败的处理配置定义在了LoginFilter 过滤器中。
以及logout登出配置,使用jwt之后这个配置也没有意义了。

第二第三步的配置也紧跟其后,但是第二第三步的TokenFilter和LoginFilter 还没实现,下面贴出。


过滤器

先准备一个Jwt的工具类

这里是简单配置的,实际使用按自己的需要设置私钥、过期时间和改变生成逻辑等。


public class JwtUtils {

    // 过期时间5分钟
    private static final long EXPIRE_TIME = 5 * 60 * 1000;

    // 私钥
    public static final String SECRET = "SECRET_VALUE";

    // 请求头
    public static final String AUTH_HEADER = "authToken";

    /**
     * 验证token是否正确
     */
    public static DecodedJWT verify(String token) throws JWTVerificationException {
        Algorithm algorithm = Algorithm.HMAC256(SECRET);
        JWTVerifier verifier = JWT.require(algorithm).build();
        DecodedJWT jwt = verifier.verify(token);
        return jwt;
    }

    /**
     * 获得token中的自定义信息,无需secret解密也能获得
     */
    public static String getClaimFiled(String token, String filed) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim(filed).asString();
        } catch (JWTDecodeException e) {
            return null;
        }
    }

    /**
     * 生成jwt
     */
    public static String createToken(String username) {
        try {
            //过期时间
            Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
            Algorithm algorithm = Algorithm.HMAC256(SECRET);
            // 附带username,nickname信息
            return JWT.create().withClaim("username", username).withJWTId(UUID.randomUUID().toString()).withExpiresAt(date).sign(algorithm);
        } catch (JWTCreationException e) {
            return null;
        }
    }

    /**
     * 获取 token的签发时间
     */
    public static Date getIssuedAt(String token) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getIssuedAt();
        } catch (JWTDecodeException e) {
            return null;
        }
    }

    /**
     * 验证 token是否过期
     */
    public static boolean isTokenExpired(String token) {
        Date now = Calendar.getInstance().getTime();
        DecodedJWT jwt = JWT.decode(token);
        return jwt.getExpiresAt().before(now);
    }

    /**
     * 刷新 token的过期时间
     */
    public static String refreshTokenExpired(String token, String secret) {
        DecodedJWT jwt = JWT.decode(token);
        Map<String, Claim> claims = jwt.getClaims();
        try {
            Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
            Algorithm algorithm = Algorithm.HMAC256(secret);
            JWTCreator.Builder builer = JWT.create().withExpiresAt(date);
            for (Map.Entry<String, Claim> entry : claims.entrySet()) {
                builer.withClaim(entry.getKey(), entry.getValue().asString());
            }
            return builer.sign(algorithm);
        } catch (JWTCreationException e) {
            return null;
        }
    }

    /**
     * 生成随机盐
     */
    public static String generateSalt() {
        String uuid = UUID.randomUUID().toString();
        //String hex = uuid.nextBytes(16).toHex();
        return uuid;
    }
}

LoginFilter 登录过滤

这个过滤器用以拦截登录url,处理验证逻辑并返回一个有效的jwt。在前两篇文章中都没有主动去配置登录过滤器,是因为SpringSecurity过滤链上已经有一个默认的登录过滤器了,名称为UsernamePasswordAuthenticationFilter。

现在我们要继承这个UsernamePasswordAuthenticationFilter得到一个自定义的Filter,并且用来代替过滤链上的UsernamePasswordAuthenticationFilter,这样就可以实现我们想要的登录逻辑了。


public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    @Resource
    private UserMapper userMapper;

    @SneakyThrows
    @Override
    public Authentication attemptAuthentication(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws AuthenticationException {
        //User user = new ObjectMapper().readValue(httpServletRequest.getInputStream(), User.class);
        User user = new User();
        user.setUsername(httpServletRequest.getParameter("username"));
        user.setPassword(httpServletRequest.getParameter("password"));
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
        Authentication authenticate = getAuthenticationManager().authenticate(usernamePasswordAuthenticationToken);
        return authenticate;
    }

    //登录成功
    @Override
    protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse resp, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        resp.setContentType("application/json;charset=utf-8");
        PrintWriter out = resp.getWriter();
        User hr = (User) authResult.getPrincipal();
        hr.setPassword(null);
        out.write(hr.getUsername()+"登录成功!");
        resp.setHeader("authToken",JwtUtils.createToken(hr.getUsername()));
        out.flush();
        out.close();
    }
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest req, HttpServletResponse resp, AuthenticationException failed) throws IOException, ServletException {
        resp.setContentType("application/json;charset=utf-8");
        PrintWriter out = resp.getWriter();
        out.write("登录失败!");
        out.flush();
        out.close();
    }
}

LoginFilter 重写了UsernamePasswordAuthenticationFilter 的三个方法:

  • .attemptAuthentication
    核心方法,生成凭证。
    看返回值类型是Authentication,翻译过来是“认证”的意思,用来存储认证信息,作用类似于shiro中的AuthorizationInfo。
    从登录参数中提取出用户名密码封装在Authentication中(演示代码用实现类UsernamePasswordAuthenticationToken ),然后调用AuthenticationManager.authenticate()方法进行自动校验。【校验逻辑其实就在前一篇文章中UserService.loadUserByUsername(String username)实现

  • .successfulAuthentication
    这个方法用来处理登录成功的情况,如果attemptAuthentication的校验成功,就会调用该方法,这里就使用JwtUtils生成了一个有效的jwt放在头部返回。前端在登录成功之后的请求就需要注意带上authToken这个头部了,另一个过滤器提取jwt参数也是用这里定义的参数名。

  • .unsuccessfulAuthentication
    顾名思义自然就是认证不成功的处理方法。

TokenFilter 令牌校验过滤器

这个过滤器算是普通的spring Filter了,直接继承GenericFilterBean在doFilter方法中实现jwt的校验逻辑。

public class TokenFilter extends GenericFilterBean {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        //1.获取jwt
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        String authToken = request.getHeader("authToken");
        if (authToken==null||authToken.isEmpty()){
            filterChain.doFilter(servletRequest,servletResponse);
            return;
        }
        String username;
        // 2.验证JWT签名
        try{
            DecodedJWT jwt = JwtUtils.verify(authToken);
            username = jwt.getClaim("username").asString();
        }catch (JWTVerificationException e){
            PrintWriter writer = servletResponse.getWriter();
            writer.write("token认证错误,请重新登录");
            writer.flush();
            writer.close();
            return;
        }
        // 3.将认证信息封装成AuthenticationToken放进上下文,否则仍会跳转到登录链接
        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, null,null);
        SecurityContextHolder.getContext().setAuthentication(token);
        //4.继续执行过滤链
        filterChain.doFilter(servletRequest,servletResponse);

    }
}

注释3处的代码,生成UsernamePasswordAuthenticationToken的时候一共有三个参数,分别是用户名、密码、权限(角色)信息,由于这里还没涉及权限后两个参数都填的null,有需要的话在登录时将权限信息封装在jwt,这里解析时再取出即可。


到这里需要的配置完毕了,在basic子工程的基础上,只新增/更改了这三个文件。
在这里插入图片描述


演示

启动服务器,用postman简单验证效果。
用户名和密码:
在这里插入图片描述

  1. 输入错误的用户名,登录失败
    在这里插入图片描述

  2. 输入正确的用户名和密码,登录成功
    在这里插入图片描述
    得到jwt
    在这里插入图片描述

  3. 不附带jwt访问接口
    在这里插入图片描述

  4. 带上jwt访问,访问成功
    在这里插入图片描述

  5. jwt输错或者过期
    在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值