SpringSecurity+JWT登录认证

1. 简要

之前学习的认证方式,在服务器验证通过后,会在当前对话session中保存数据,然后向客户端返回一个session_id存在客户端Cookie中,但是这种模式会存在问题就是扩展性不好,对于单机还好,如果是服务器集群,就需要实现session共享,保证每台服务器都能读取session。

而今天要学的JWT(JSON Web Token)是目前比较留校的一种跨域认证方案,在前后端分离项目中应用的比较多。具体JWT的概念可以学习阮一峰大神的这篇文章:[JSON Web Token 入门教程 - 阮一峰](https://link.juejin.cn/?target=http%3A%2F%2Fwww.ruanyifeng.com%2Fblog%2F2018%2F07%2Fjson_web_token-tutorial.html)

俺今天是要使用SpringSecurity实现JWT的认证,前后端分离,使用JSON交互。

2. 设计思路

首先是登录认证:

  1. 前端POST请求,将用户名和密码以JSON的形式发送请求/jwt/login
  2. 自定义一个JWTAuthenticationFilter,进行提取request中的参数,封装为一个UsernampasswordAuthenticationTokenAuthenticationManager进行认证
  3. AuthenticationManager从自定义的Service中查找用户信息,判断账号密码是否正确
  4. 认证成功则生成JWT Token给客户端
  5. 认证失败则返回错误

然后是请求时的Token校验:

因为使用JWT,所以不需要用到session,每一个请求都是需要携带Token,服务器根据Token进行验证,因此这里我们还需要一个自定义的Filter,用来拦截任意请求
  1. 一个请求发起
  2. 被自定义的Filter拦截,进行token验证
  3. 将验证成功的token生成Authentication对象存入SecurityContext上下文,表示验证完成
  4. 后续再进行权限的验证

3. 环境搭建


    
        org.springframework.boot
        spring-boot-starter-security
    
    
        org.springframework.boot
        spring-boot-starter-web
    
    
        io.jsonwebtoken
        jjwt
        0.9.1
    
    
        org.mybatis.spring.boot
        mybatis-spring-boot-starter
        2.2.2
    
    
        mysql
        mysql-connector-java
        5.1.48
        runtime
    
    
    
        javax.xml.bind
        jaxb-api
        2.3.0
    

    
        org.springframework.boot
        spring-boot-starter-test
        test
    
    
        org.springframework.security
        spring-security-test
        test
    
    
        org.projectlombok
        lombok
    

这里需要注意的一个包是:jaxb-api

导入这个包是因为使用JJWT这个包时会报一个异常:

java.lang.ClassNotFoundException: javax.xml.bind.DatatypeConverter at java.base/jdk.internal.loader

导入这个包就可以解决了。

Springboot配置mysql和mybatis

spring:
  datasource:
    username: root
    password: root
    url: jdbc:mysql://localhost:3306/security
    driver-class-name: com.mysql.jdbc.Driver

mybatis:
  mapper-locations:
  - classpath:mapper/*.xml
  type-aliases-package: com.zzy.jwt.pojo

4. 源码实现

  1. 自定义JWTAuthenticationFilter

    public class JWTAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    
        /**
         * 通过构造器注入拦截的Url,请求方法没有限制
         * @param antPathRequestUrl url
         */
        public JWTAuthenticationFilter(String antPathRequestUrl) {
            super(new AntPathRequestMatcher(antPathRequestUrl, "POST"));
        }
    
        @Override
        public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
            // 从JSON中读取请求参数
            User user = new ObjectMapper().readValue(request.getInputStream(), User.class);
    
            // 获取用户名和密码
            String username = user.getUsername();
            String password = user.getPassword();
    
            // 构造Token,使用UsernamepasswordAuthenticationToken
            UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);
            // 设置一些客户端IP等信息
            setDetails(request, token);
            // 交给AuthenticationManager进行认证
            return this.getAuthenticationManager().authenticate(token);
        }
    
        protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) {
            authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
        }
    
        @Override
        protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
            ObjectMapper objectMapper = new ObjectMapper();
            Response myResponse = new Response();
            // 设置header
            response.setHeader("Content-Type", "application/json;charset=utf-8");
            User user = (User) authResult.getPrincipal();
            // 生成Token返回
            String token = JWTUtil.generate(user);
            myResponse.setStatusCode(HttpStatus.OK.value());
            myResponse.setMsg("登录成功!");
            myResponse.setData("Bearer " + token);
    
            response.setStatus(HttpStatus.OK.value());
            response.getWriter().write(objectMapper.writeValueAsString(myResponse));
        }
    
    
        /**
         * 重写认证失败后的处理方法
         * @param request
         * @param response
         * @param failed
         * @throws IOException
         * @throws ServletException
         */
        protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                                  AuthenticationException failed) throws IOException, ServletException {
            ObjectMapper objectMapper = new ObjectMapper();
            Response myResponse = new Response();
            myResponse.setStatusCode(HttpStatus.UNAUTHORIZED.value());
            if (failed instanceof LockedException) {
                myResponse.setMsg("账号被锁定!");
            } else if (failed instanceof CredentialsExpiredException) {
                myResponse.setMsg("用户密码过期!");
            } else if (failed instanceof AccountExpiredException) {
                myResponse.setMsg("用户账号过期!");
            } else if (failed instanceof DisabledException) {
                myResponse.setMsg("用户账号被禁用!");
            } else if (failed instanceof BadCredentialsException) {
                myResponse.setMsg("用户账号或密码错误!");
            }
    
            response.setContentType("application/json;charset=utf-8");
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            response.getWriter().write(objectMapper.writeValueAsString(myResponse));
        }
    

    自定义登录认证过滤器,这次拦截的url由构造器进行设置,重写attemptAuthentication方法逻辑与之前的自定义认证一样,都是从请求中获取用户名和密码等参数,封装为一个Token,我这里直接使用了官方的UsernamepasswordAuthenticationToken,然后交给AuthenticationManager去认证。

    这里的认证成功与失败没有使用自定义类的形式实现,而是以重写successfulAuthentication方法和unsuccessfulAuthentication方法实现,认证成功后生成一个JWT Token返回,这里直接将user对象作为JWT的playload保存,实际中应以实际情况写入的。认证失败则返回JSON字符串错误。

    需要与之前的验证码认证区分开的是,这里前后端是以JSON传递的,所以request接收到的是一个json字符串的数据,返回时也是json数据,相同的地方是Provider还是调用UserDetailsService的loadUserByUsername方法去验证用户。

    在这里也是自定义了一个Response类,用来构造返回信息,主要就是设置状态,返回信息,以及数据。

  2. 定义UserDetail类和UserDetailsService

    UserDetail类:

    @Data
    @JsonIgnoreProperties(ignoreUnknown = true)
    public class User implements UserDetails, Serializable {
    
        private static final long serialVersionUID = -1384206000980290583L;
    
        private int id;
        private String username;
        private String password;
        private String email;
        private boolean enabled;
    
        @Override
        public String toString() {
            return "User{" +
                    "id=" + id +
                    ", username='" + username + '\'' +
                    ", password='" + password + '\'' +
                    ", email='" + email + '\'' +
                    '}';
        }
    
        @Override
        @JsonDeserialize(using = CustomAuthorityDeserializer.class)
        public Collection getAuthorities() {
            List authorities = new ArrayList<>();
            authorities.add(new SimpleGrantedAuthority("ROLE_ROOT"));
            return authorities;
        }
    
        @Override
        public boolean isAccountNonExpired() {
            return true;
        }
    
        @Override
        public boolean isAccountNonLocked() {
            return true;
        }
    
        @Override
        public boolean isCredentialsNonExpired() {
            return true;
        }
    
        @Override
        public boolean isEnabled() {
            return enabled;
        }
    }
    

    User类没有太多变动,和之前一样,因为还没涉及到授权部分,这里暂时先写死权限位ROLE_ROOT.

    有两个错误可能会出现:

    1. com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field "accountNonExpired" (class com.zzy.jwt.pojo.User), not marked as ignorable

      这是因为我们user类中一些字段在数据库中是没有的,需要在类上加上一个注解:@JsonIgnoreProperties(ignoreUnknown = true)

      解决方案来源于这个博客:jackson json转bean忽略没有的字段 not marked as ignorable

    2. com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of org.springframework.security.core.GrantedAuthority

      这是因为我们在构造JWT Token时候是将整个user对象写入的,user类中getAuthorities方法导致的,需要在getAuthorities方法上加上一个注解@JsonDeserialize(using = CustomAuthorityDeserializer.class)并定义一个类:CustomAuthorityDeserializer

      具体解决方式可以参考这个博文:Cannot construct instance of org.springframework.security.core.GrantedAuthority的错误解决

    UserDetailsService:

    @Service
    public class UserService implements UserDetailsService {
    
        @Autowired
        private UserMapper userMapper;
    
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            User user = userMapper.findUserByUsername(username);
            if (user == null) {
                throw new UsernameNotFoundException("User: " + username + " not exist!");
            }
    
            return user;
        }
    }
    

    Provider通过调用UserService的方法loadUserByUsername去认证用户,这里的方法里通过用户名查询数据库。

    当然这个UserService是我们自定义的实现UserDetailsService的,在后面我们的Security配置中要设置为这个Service

    数据库操作相关的比较简单,具体再看gitee上的源码吧,哈哈。

  3. JWT工具类

    public class JWTUtil {
        // 密钥
        private final static String secretKey = "Lucas";
    
        // 过期时间设置为5min
        private final static Duration expiration = Duration.ofMinutes(10);
    
        /**
         * 生成Token
         * @param user
         * @return
         */
        public static String generate(User user) {
            // 过期时间
            Date expiryDate = new Date(System.currentTimeMillis() + expiration.toMillis());
            try {
                return Jwts.builder()
                    .setSubject(new ObjectMapper().writeValueAsString(user))       // 将username放进JWT
                    .setIssuedAt(new Date())    // 设置jwt签发时间
                    .setExpiration(expiryDate)  // 设置jwt过期时间
                    .signWith(SignatureAlgorithm.HS512, secretKey)  // 设置加密算法和密钥
                    .compact();
            } catch (Exception e) {
                return null;
            }
        }
    
        /**
         * 解析Token
         * @param token
         * @return
         */
        public static Claims parse(String token) {
            try {
                return Jwts.parser()
                    .setSigningKey(secretKey)
                    .parseClaimsJws(token)
                    .getBody();
    
            } catch (Exception e) {
                return null;
            }
        }
    
    }
    

    这里只是简单的构造,一个生成Token的方法,一个解析Token的方法,密钥设置为字符串"Lucas", 过期时间设置为10分钟。

    更加细致的工具方法暂未去实现,先凑合着用着。

    其实,到了这里我们就差将我们自定义的Filter和Provider添加到SpringSecurity的主配置中,就实现了认证了,但我们这次用的是JWT方式,服务器端并没有记录登录用户的信息,因此我们需要多一个过滤器,用来拦截请求,验证Token的,如果请求携带了Token,并且token里的信息是正确的,我们才将Authentication设置已认证,让SpringSecurity过滤器链去做后续的鉴权啥的。

  4. 自定义Token拦截器

    SpringSecurity默认的基础配置中没有提供对Bearer Authentication处理的过滤器,但是提供了处理Basic Authentication的过滤器BasicAuthenticationFilter,我们还是可以模仿它实现自己的Filter

    public class JWTAuthenticationRequestFilter extends OncePerRequestFilter {
    
        private UserDetailsService userDetailsService;
    
        public JWTAuthenticationRequestFilter(UserDetailsService userDetailsService) {
            this.userDetailsService = userDetailsService;
        }
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
            // 校验Token
            Authentication authentication = getAuthentication(request);
            if (authentication == null) {
                filterChain.doFilter(request, response);
                return;
            }
            // 认证成功, 保存到SecurityContext上下文中
            SecurityContextHolder.getContext().setAuthentication(authentication);
            filterChain.doFilter(request, response);
        }
    
        private Authentication getAuthentication(HttpServletRequest request) throws JsonProcessingException {
            // 判断是否有Token,拿到Token
            String header = request.getHeader("Authorization");
            if (header == null || !header.startsWith("Bearer ")) {
                return null;
            }
            String token = header.split(" ")[1];
            System.out.println("token = " + token);
            // 通过Token解析
            Claims claims = JWTUtil.parse(token);
            if (claims == null) {
                return null;
            }
            User user = (User) new ObjectMapper().readValue(claims.getSubject(), User.class);
    
            // 开始验证
            // 通过用户名查找数据库,确认是否存在该用户
            UserDetails userDetails = null;
            try {
                userDetails = userDetailsService.loadUserByUsername(user.getUsername());
            } catch (UsernameNotFoundException e) {
                return null;
            }
    
            // 校验Token的密码与数据库中用户密码是否一致
            if (!userDetails.getPassword().equals(user.getPassword())) {
                return null;
            }
            // 构造Authentication对象
            return new UsernamePasswordAuthenticationToken(userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities());
        }
    }
    

    简单说明下这里的逻辑:

    1. 对于任意的请求,都会被该过滤器拦截,首先会判断是否携带了token,这里设置是请求头Authentication中放置Token
    2. 接着对Token进行解析,拿出我们存放进去的user类对象
    3. 调用userService调用loadUserByUsername去查询数据库,确定是否存在该用户
    4. 校验数据库中的用户密码是否与Token中的用户密码信息一致,如果一致则构造一个可信的Authentication对象,这里是UsernamepasswordAuthenticationToken;如果不一致,则返回null
    5. 若Token校验不通过,则放行,SpringSecurity后续的过滤器会处理掉他的。
    6. 若Token校验成功,则将校验对象Authentication放入SpringContext上下文中
  5. 自定义SpringSecurity中的异常

    前面一些处理报异常时并没有返回到客户端,我们还需要自定义SpringSecurity的全局异常处理,返回JSON数据给到客户端。

    具体的操作可以学习小胖哥这篇博文:Spring Security 实战干货:自定义异常处理

    权限相关异常处理:

    public class SimpleAccessDeniedHandler implements AccessDeniedHandler {
    
        @Override
        public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
            Response myResponse = new Response();
            myResponse.setStatusCode(HttpServletResponse.SC_FORBIDDEN);
            myResponse.setMsg("没有权限!");
    
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            response.setCharacterEncoding("utf-8");
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    
            response.getWriter().write(new ObjectMapper().writeValueAsString(myResponse));
        }
    }
    

    认证相关异常处理:

    public class SimpleAuthenticationEntryPoint implements AuthenticationEntryPoint {
        @Override
        public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
            Response myResponse = new Response();
            myResponse.setStatusCode(HttpServletResponse.SC_UNAUTHORIZED);
            myResponse.setMsg(authException.getMessage());
    
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setCharacterEncoding("utf-8");
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    
            response.getWriter().write(new ObjectMapper().writeValueAsString(myResponse));
        }
    }
    
  6. 配置

    我们还是自定义一个配置类继承SecurityConfigurerAdapter,配置我们自定义的Filter

    @Component
    public class JWTLoginConfig extends SecurityConfigurerAdapter {
    
        @Autowired
        private UserService userService;
    
        @Override
        public void configure(HttpSecurity http) throws Exception {
            // 配置Filter
            JWTAuthenticationFilter jwtAuthenticationFilter = new JWTAuthenticationFilter("/jwt/login");
            JWTAuthenticationRequestFilter jwtAuthenticationRequestFilter = new JWTAuthenticationRequestFilter(userService);
    
            // 配置AuthenticationManager
            jwtAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
    
            // 添加Filter
            http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
            http.addFilterBefore(jwtAuthenticationRequestFilter, UsernamePasswordAuthenticationFilter.class);
        }
    }
    

    注意的点:

    1. 这里虽说是一个配置类,但是最后是要并入SpringSecurity的主配置的,这里用的注解是@Component,能让我们在主配置类中进行注入。
    2. 自定义的认证Filter一定要设置AuthenticationManager,否则找不到对应的Provider来处理。

    主配置类:

    @Configuration
    public class MySecurityConfig extends WebSecurityConfigurerAdapter {
    
        @Autowired
        private JWTLoginConfig jwtLoginConfig;
    
        @Autowired
        private UserService userService;
    
        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    
        @Override
        public void configure(AuthenticationManagerBuilder builder) throws Exception {
            builder.userDetailsService(userService).passwordEncoder(passwordEncoder());
        }
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.authorizeRequests()
                .antMatchers("/hello")
                .hasRole("ROOT")
                .anyRequest()
                .authenticated()
                .and()
                .apply(jwtLoginConfig)
                .and()
                .csrf()
                .disable();
    
            // 自定义异常处理
            http.exceptionHandling().accessDeniedHandler(new SimpleAccessDeniedHandler()).authenticationEntryPoint(new SimpleAuthenticationEntryPoint());
            // 前后端分离是STATELESS,session使用该策略
            http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        }
    }
    

    这里我们自定义了UserDetailService方法,因此需要配置上builder.userDetailsService(userService).passwordEncoder(passwordEncoder());,密码编码器不可少。

    然后自定义异常的话就得使用http.exceptionHandling().accessDeniedHandler(new SimpleAccessDeniedHandler()).authenticationEntryPoint(new SimpleAuthenticationEntryPoint());将我们自定义的类传入

    最后是将session策略设置为了STATELESS.

5. 测试

我简单写了一个接口/hello

@RestController
public class UserController {

    @GetMapping("/hello")
    public String hello() {
        return "hello jwt!";
    }
}

通过Postman进行测试验证:

  1. 访问/jwt/login登录

    返回了一个Token,拿着这个Token去做请求

  2. 访问/hello

    在发起请求时,在header部分添加一个key为Authentication,value为登录请求时返回的Token,才能正常访问,获得回复。

  3. 当Token过期时,再去访问/hello

    返回了401的错误,这是咱们自定义的异常处理器SimpleAuthenticationEntryPoint返回了信息

  4. 我们在定义User类时默认写死了权限为ROLE_ROOT,那当我们将/hello的访问权限修改为ROLE_USER时去访问

    http.authorizeRequests().antMatchers("/hello").hasRole("USER")
    

返回了403错误,这是自定义异常处理器SimpleAccessDeniedHandler返回的信息

6. JWT存在的问题

  1. 续签问题,传统的cookie+session的方案天然支持续签,但是jwt由于服务端不保存用户状态了,因此很难完美解决续签问题
  2. 注销问题,因为服务器不保存用户信息,所以一般可以通过修改secret来实现注销,但会有个问题,服务端的secret修改后,已经颁发的还没过期的token就会认证失败。
  3. 一般建议不同用户取不同的secret

你可能感兴趣的:(SpringSecurity+JWT登录认证)