Spring Boot + Spring Security + Mybatis:配置token,分配不同权限

376 阅读6分钟

本文框架搭建主要参考 github.com/Snailclimb/… 该文仅对其中的一些关键代码和问题进行分析

项目需求

  1. Spring Boot版本为2.0.3;
  2. Spring Security版本为5.1.6;
  3. Mybatis版本为1.3.2
  4. 使用插件lombook

效果展示

使用postman来进行效果展示。其中一些效果需要配合我的下一篇文章中介绍的filter异常捕获来实现。

  1. 正确登录,返回的header中key为Authorization的value即为token。
  2. 账号错误
  3. 密码错误
  4. header中携带正确token访问
  5. 携带错误或者过期的token
  6. 访问权限不足

1. 创建用户实体User与权限实体UserRole

@Entity
@Data
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    private String username;

    private String password;
}
@Data
@Entity
public class UserRole {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;

    private String role;

}

在数据库中建立对应数据表。 构建Mapper,查询语句为

@Select("select * from user_role where username = #{username}")
    List<UserRole> getUserRoleByUserName(String username);

2. 创建登录实体

该实体仅用于登录功能

@Data
public class LoginRequest {
    private String username;
    private String password;
}

3. 实现UserDetails

该项为spring security中必须实现的接口。

private Collection<? extends GrantedAuthority> authorities;

public JwtUser(User user) {
        this.id = user.getId();
        this.username = user.getUsername();
        this.password = user.getPassword();
        this.authorities = CurrentUserUtils.getCurrentUserRoles(this.username);
    }

其中,getCurrentUserRoles为工具类中的一个静态方法,用于获取当前登录用户的权限。

public static List<SimpleGrantedAuthority> getCurrentUserRoles(String username) {
        List<UserRole> userRoles = currentUserUtils.userService.getUserRolesByUsername(username);//在这里加了一层userService的封装,也可以直接调用mapper
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        userRoles.forEach(userRole -> authorities.add(new SimpleGrantedAuthority("ROLE_" + userRole.getRole())));
        return authorities;
    }

注意,在工具类中注入Bean需要使用如下代码,否则使用@Autrowired,Spring无法找到相应Bean注入

@PostConstruct
    public void init() {
        currentUserUtils = this;
        currentUserUtils.userService = this.userService;
    }

4. 实现UserDeatilsService

该项为spring security中必须实现的接口,必须实现其中的loadUserByUsername()函数,来正确地通过用户名来找到相应的用户。该函数会抛出UsernameNotFountException。但是要注意,spring security默认将该异常转换为BadCredentialsException,可以通过在配置中进行配置实现正确抛出该异常,与密码错误相区别,该内容将在下方配置内容中介绍。

@Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userService.findUserByUsername(username);
        if(user==null) {
            throw new UsernameNotFoundException("用户名不存在!");
        }
        return new JwtUser(user);
    }

5. Token创建工具类

使用工具类JwtTokenUtils.java,通过io.jsonwebtoken.Jwts来实现token的生成,具体代码如下

private static final byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(SecurityConstants.JWT_SECRET_KEY);
//JWT_SECRET_KEY = "C*F-JaNdRgUkXn2r5u8x/A?D(G+KbPeShVmYq3s6v9y$B&E)H@McQfTjWnZr4u7w";用于加密
private static final Key secretKey = Keys.hmacShaKeyFor(apiKeySecretBytes);

public static String createToken(String username, List<String> roles) {
        final Date createdDate = new Date();
        final Date expirationDate = new Date(createdDate.getTime() + SecurityConstants.EXPIRATION * 1000);
        //EXPIRATION = 60 * 60L;过期时间为1小时
        String tokenPrefix = Jwts.builder()
                .setHeaderParam("type", SecurityConstants.TOKEN_TYPE)
                //TOKEN_HEADER = "Authorization";返回的header字段
                .signWith(secretKey, SignatureAlgorithm.HS256)
                //加密算法
                .claim(SecurityConstants.ROLE_CLAIMS, String.join(",", roles))
                //ROLE_CLAIMS = "rol";将权限加密
                .setIssuer("Ddm")
                //签发者
                .setIssuedAt(createdDate)
                //创建时间
                .setSubject(username)
                .setExpiration(expirationDate)
                .compact();
        return SecurityConstants.TOKEN_PREFIX + tokenPrefix;
    }

6. 自定义过滤器filter实现

1. 授权过滤器

授权就是在用户登录的时候,验证他的登录信息,如果准确无误,就为他创建一个token并在header中进行返回。

自定义授权过滤器需要实现UsernamePasswordAuthenticationFilter(),主要是实现attemptAuthentication()、successfulAuthentication()、unsuccessfullAuthentication()这三个函数。

实现自定义授权过滤器的目的如下:

    1. 通过自定义的登录信息来正确地验证用户名、密码等信息,如果有除了username、password外的信息,或者username、password字段被修改了,无法正确获取。实际上不太会发生这种情况,所以该函数也可以选择不重写。
    1. UsernamePasswordAuthenticationFilter()中没有successfulAuthentication()的默认实现,需要我们来实现用户名密码验证完成后的授权工作。我们通过自定义的token创建工具(即JwtTokenUtils中的createToken())来创建token,可以与之后的token解析相互对应。
    1. 简单实现unsuccessfulAuthentication(),如果验证用户名与密码错误,就会进入到该函数。
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
                                                HttpServletResponse response) throws AuthenticationException {

        ObjectMapper objectMapper = new ObjectMapper();
        try {
            // 获取登录的信息,源码中通过request.getParameter来获取,实际效果一样
            LoginRequest loginRequest = objectMapper.readValue(request.getInputStream(), LoginRequest.class);
            // 这部分和attemptAuthentication方法中的源码是一样的
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                    loginRequest.getUsername(), loginRequest.getPassword());
            return authenticationManager.authenticate(authentication);//验证用户名与密码
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 如果验证成功,就生成token并返回
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            FilterChain chain,
                                            org.springframework.security.core.Authentication authentication) {

        JwtUser jwtUser = (JwtUser) authentication.getPrincipal();
        List<String> authorities = jwtUser.getAuthorities()
                .stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList());
        // 创建 Token
        String token = JwtTokenUtils.createToken(jwtUser.getUsername(), authorities);
        // Http Response Header 中返回 Token
        response.setHeader(SecurityConstants.TOKEN_HEADER, token);
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException authenticationException) throws IOException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authenticationException.getMessage());
    }

2. 认证过滤器

用户已经得到了授权,但是想要访问我们的其他服务,必须要在header中携带我们之前为他生成的token,这步token验证称为认证,只有通过了认证的用户,才能正确地访问到相应的服务。

自定义认证过滤器主要是重写一下doFilterInternal这个函数,让它通过我们自定义的JwtTokenUtils来获取相应的信息,否则用默认方法,无法得到正确的信息。

 @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws IOException, ServletException {

        String token = request.getHeader(SecurityConstants.TOKEN_HEADER);
        if (token == null || !token.startsWith(SecurityConstants.TOKEN_PREFIX)) {
            SecurityContextHolder.clearContext();
        } else {
            UsernamePasswordAuthenticationToken authentication = getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }

    /**
     * 获取用户认证信息 Authentication
     */
    private UsernamePasswordAuthenticationToken getAuthentication(String authorization) {
        String token = authorization.replace(SecurityConstants.TOKEN_PREFIX, "");
        try {
            String username = JwtTokenUtils.getUsername(token);
            logger.info("checking username:" + username);
            if (!StringUtils.isEmpty(username)) {
                // 这里我们是又从数据库拿了一遍,避免用户的角色信息有变
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(username, null, userDetails.getAuthorities());
                return userDetails.isEnabled() ? usernamePasswordAuthenticationToken : null;
            }
        } catch (ExpiredJwtException | MalformedJwtException | IllegalArgumentException exception) {
            logger.warning("Request to parse JWT with invalid signature . Detail : " + exception.getMessage());
        }
        return null;
    }

7. 异常处理

1. token错误或者过期

通过实现AuthenticationEntryPoint()函数来解决该异常信息。

public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    /**
     * 当用户尝试访问需要权限才能的REST资源而不提供Token或者Token错误或者过期时,
     * 将调用此方法发送401响应以及错误信息
     */
    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, SecurityConstants.TOKEN_ILLEGAL);
    }
}

2. 访问权限不足

比如一个资源必须拥有ADMIN权限才可以访问,USER权限访问就会被拦截,通过实现AcessDeniedHandler()来处理该问题。

public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    /**
     * 当用户尝试访问需要权限才能的REST资源而权限不足的时候,
     * 将调用此方法发送403响应以及错误信息
     */
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        response.sendError(HttpServletResponse.SC_FORBIDDEN, SecurityConstants.AUTHORITY_DENY);
    }
}

8. 总体配置

  1. 先将刚才定义的userDetailsServiceImpl,两个异常处理以及BCryptPasswordEncoder(密码加密器,使用后在数据库中无法看到正确的密码)作为@Bean进行注入或初始化。此处,我们就可以通过setHideUserNotFountExceptions(false)来让spring正确返回UsernameNotFoundException。
 @Bean
    public DaoAuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setHideUserNotFoundExceptions(false);//用于区分用户名错误与密码错误
        provider.setPasswordEncoder(bCryptPasswordEncoder());
        provider.setUserDetailsService(userDetailsServiceImpl);
        return provider;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) {
        // 设置自定义的userDetailsService以及密码编码器
        auth.authenticationProvider(authenticationProvider());
    }
  1. 重写configure(AuthenticationManagerBuilder auth)与configure(HttpSecurity http)。
@Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and()
                // 禁用 CSRF
                .csrf().disable()
                .authorizeRequests()
                .antMatchers(HttpMethod.POST, "/auth/*").permitAll()
                // 指定路径下的资源需要验证了的用户才能访问)
                .antMatchers(HttpMethod.GET, "/api/**").hasRole("USER")
                .antMatchers(HttpMethod.DELETE, "/api/**").hasRole("ADMIN")
                // 其他都放行了
                .anyRequest().permitAll()
                .and()
                //添加自定义Filter
                .addFilter(new JwtAuthenticationFilter(authenticationManager()))
                .addFilter(new JwtAuthorizationFilter(authenticationManager(), userDetailsServiceImpl))
                // 不需要session(不创建会话)
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                // 授权异常处理
                .exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint())
                .accessDeniedHandler(jwtAccessDeniedHandler());
        // 防止H2 web 页面的Frame 被拦截
        http.headers().frameOptions().disable();
    }

至此,spring security已经全部配置完毕,已经可以实现token授权与认证功能了。